From dfba82ed90bc89346747d2df389ffe2ee43c87f4 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Sun, 1 Nov 2026 20:59:59 -0800 Subject: [PATCH 1/3] feat(mcp): start a Solo chat with channel_create channel_create takes solo and members, so a caller outside Buzz, such as the standalone stdio server, can start a native Solo chat signed by its own key: a private stream with the buzz.solo label and its members in the create, as the app starts one. It reads back the label and roster, and channel_get shows solo. Ordinary creates are unchanged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- docs/solo/README.md | 7 ++ src/buzz-mcp/tools.test.ts | 153 ++++++++++++++++++++++++++++++++++++- src/buzz-mcp/tools.ts | 52 ++++++++++++- 3 files changed, 207 insertions(+), 5 deletions(-) diff --git a/docs/solo/README.md b/docs/solo/README.md index d4d22f81e7..fc8f9c19a5 100644 --- a/docs/solo/README.md +++ b/docs/solo/README.md @@ -70,6 +70,13 @@ behavior on the label, so a rename touches copy only. agents: answer as one channel-scoped, top-level conversation. It is not access authority. The in-app agent harness reads it from the channel list; remote harnesses that only read `about` need to learn the label. +- The Buzz tools' `channel_create` with `solo` sends the same create for any + caller, including the standalone stdio server: signed by the caller, with the + label and any `members` it names. The caller is the creator, so an agent's + key makes a chat its human members see as shared, not their own. It sends no + message, so no agent turn starts, and it does not file the chat in a + workspace. It cannot check NIP-CL support first, so on a relay that drops the + label it reports an ordinary private channel instead. - **Share** opens the same access dialog from Solo and Messages. This flow preserves channel ID, history, Canvas, existing members, Solo label and workspace filing. It neither promotes a conversation nor diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 8885b2300b..342dd93ba7 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1270,7 +1270,13 @@ function relay(refused: Readonly> = {}) { client.publish = vi.fn(async (template: Template) => { const id = template.tags.find((t) => t[0] === "h")?.[1] ?? ""; const target = template.tags.find((t) => t[0] === "p")?.[1] ?? ""; - if (refused[target]) throw new Error(refused[target]); + // Like the relay, any refused member keeps a whole create out. + const refusal = ( + template.kind === 9007 + ? template.tags.flatMap(([key, value]) => (key === "p" ? [value] : [])) + : [target] + ).find((key) => key && refused[key]); + if (refusal) throw new Error(refused[refusal]); published.push(template); const { kind } = template; if (kind === 9007) { @@ -1279,6 +1285,11 @@ function relay(refused: Readonly> = {}) { const value = (key: string) => template.tags.find((t) => t[0] === key)?.[1]; const type = ["t", value("channel_type") ?? "stream"]; + // Labels follow the type; `p` members join in the create. + const labels = template.tags.filter((t) => t[0] === "t"); + const members = template.tags.flatMap(([key, value]) => + key === "p" && value ? [["p", value, "", "member"]] : [], + ); state.push( event({ kind: 39000, @@ -1286,12 +1297,13 @@ function relay(refused: Readonly> = {}) { ["d", id], ["name", value("name") ?? ""], type, + ...labels, ...(value("visibility") === "private" ? [["private"]] : []), ], }), event({ kind: 39002, - tags: [["d", id], type, ["p", me, "", "owner"]], + tags: [["d", id], type, ["p", me, "", "owner"], ...members], }), ); } @@ -1495,6 +1507,143 @@ it("creates a system channel", async () => { ).rejects.toThrow("type must be stream, forum or system."); }); +it("creates a Solo chat with its members in the create, as Buzz does", async () => { + const { client, published } = relay(); + const answer = await callTool(client, {}, "channel_create", { + name: "triage", + visibility: "private", + solo: true, + members: [alice, npubEncode(bob), alice, me], + }); + const id = published[0]?.tags[0]?.[1] as string; + expect(published).toEqual([ + { + kind: 9007, + content: "", + tags: [ + ["h", id], + ["name", "triage"], + ["visibility", "private"], + ["channel_type", "stream"], + ["t", "buzz.solo"], + ["t", "buzz.session.named"], + ["p", alice], + ["p", bob], + ], + }, + ]); + expect(answer).toBe( + `created ${id}\nadded ${alice} Alice Smith\nadded ${bob}`, + ); + await expect( + callTool(client, {}, "channel_get", { channel: id }), + ).resolves.toBe(`${id} triage (stream, solo, private)\nyou: owner`); + await expect( + callTool(client, {}, "member_list", { channel: id }), + ).resolves.toBe(`${me} (owner)\n${alice} Alice Smith\n${bob} `); + // An ordinary private channel is not Solo. + await callTool(client, {}, "channel_create", { + name: "ops", + visibility: "private", + }); + const plain = published[1]?.tags[0]?.[1] as string; + expect(published[1]?.tags.some(([key]) => key === "t" || key === "p")).toBe( + false, + ); + await expect( + callTool(client, {}, "channel_get", { channel: plain }), + ).resolves.toBe(`${plain} ops (stream, private)\nyou: owner`); +}); + +it("refuses Solo arguments that do not fit, before publishing", async () => { + const { client, published } = relay(); + const create = (args: Record) => + callTool(client, {}, "channel_create", { + name: "x", + visibility: "private", + ...args, + }); + const shape = "A Solo chat is a private stream without about."; + await expect(create({ solo: true, visibility: "open" })).rejects.toThrow( + shape, + ); + await expect(create({ solo: true, type: "forum" })).rejects.toThrow(shape); + await expect(create({ solo: true, about: "notes" })).rejects.toThrow(shape); + await expect(create({ members: [alice] })).rejects.toThrow( + "members is for Solo chats.", + ); + await expect(create({ solo: true, members: ["alice"] })).rejects.toThrow( + "Not a pubkey: alice", + ); + await expect(create({ solo: true, members: alice })).rejects.toThrow( + "members must be a list of pubkeys.", + ); + await expect(create({ solo: "true" })).rejects.toThrow( + "solo must be true or false.", + ); + const many = Array.from({ length: 33 }, (_, n) => + n.toString(16).padStart(64, "0"), + ); + await expect(create({ solo: true, members: many })).rejects.toThrow( + "A Solo chat starts with at most 32 members.", + ); + expect(published).toEqual([]); +}); + +it("creates no Solo chat when a member refuses, and says what it cannot confirm", async () => { + const { client, published, state } = relay({ + [alice]: "blocked: this agent only joins its owner's channels", + }); + await expect( + callTool(client, {}, "channel_create", { + name: "triage", + visibility: "private", + solo: true, + members: [bob, alice], + }), + ).rejects.toThrow("blocked: this agent only joins its owner's channels"); + expect(published).toEqual([]); + expect(state.filter((e) => e.kind === 39000)).toHaveLength(1); + + vi.useFakeTimers(); + try { + // A relay without channel labels drops them and keeps the channel. + const publish = client.publish; + client.publish = vi.fn(async (template: Template) => + publish({ + ...template, + tags: template.tags.filter(([key]) => key !== "t"), + }), + ); + let answer = callTool(client, {}, "channel_create", { + name: "triage", + visibility: "private", + solo: true, + members: [bob], + }); + await vi.runAllTimersAsync(); + let id = published.at(-1)?.tags[0]?.[1] as string; + await expect(answer).resolves.toBe( + `created ${id}\nThe community did not keep the Solo label, so it is an ordinary private channel.\nadded ${bob}`, + ); + // A create the community never shows leaves its members unconfirmed. + client.query = vi.fn(async () => []); + answer = callTool(client, {}, "channel_create", { + name: "triage", + visibility: "private", + solo: true, + members: [bob], + }); + await vi.runAllTimersAsync(); + id = published.at(-1)?.tags[0]?.[1] as string; + await expect(answer).resolves.toBe( + `The community accepted channel ${id} but does not show it yet; its members are not confirmed.`, + ); + } finally { + vi.useRealTimers(); + } +}); + it("finds a channel past the relay's first page, by name and then by ID", async () => { const { client, state, published } = relay(); const channel = (n: number, name: string, created_at: number) => { diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 6782d239a1..a6b18b6a8e 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -29,6 +29,8 @@ import { import { newer } from "../features/relay/events"; import { threadReference } from "../features/relay/thread-reference"; import { soloChannelMetadata } from "../features/relay/discovery"; +import { channelLabelTags } from "../features/relay/channel-labels"; +import { SOLO_LABEL, SOLO_NAMED_LABEL } from "../features/solo/labels"; import type { RelayEvent } from "../features/relay/events"; import { attestedOwner } from "../features/agents/owner-attestation"; import type { @@ -222,7 +224,7 @@ export const TOOLS: readonly Tool[] = [ }), tool( "channel_create", - "Create a channel. template: one of your owner's, by name.", + "Create a channel. template: one of your owner's, by name. solo: a private Solo chat, with members added in the create.", { name: str, visibility: { type: "string", enum: ["open", "private"] }, @@ -236,6 +238,8 @@ export const TOOLS: readonly Tool[] = [ ttl, join_role: joinRole, template: str, + solo: { type: "boolean" }, + members: { ...strs, description: "Solo only: pubkeys or npubs." }, }, ["name", "visibility"], ), @@ -1421,6 +1425,7 @@ class Tools { )?.role; const flags = [ tag(metadata, "t"), + soloChannelMetadata(metadata as RelayEvent) ? "solo" : "", has(metadata, "private") ? "private" : "open", has(metadata, "archived") ? "archived" : "", tag(metadata, "ttl") @@ -1450,6 +1455,18 @@ class Tools { const role = joinRoleArg(args); if (role === "guest" && args.visibility === "private") throw new Error("join_role guest is for open channels."); + if (args.solo !== undefined && typeof args.solo !== "boolean") + throw new Error("solo must be true or false."); + const solo = args.solo === true; + const members = keys(args, "members").filter( + (key) => key !== this.client.pubkey, + ); + if (!solo && args.members !== undefined) + throw new Error("members is for Solo chats."); + if (solo && (args.visibility !== "private" || type !== "stream" || about)) + throw new Error("A Solo chat is a private stream without about."); + if (members.length > 32) + throw new Error("A Solo chat starts with at most 32 members."); // Found first, so a wrong name creates nothing. const template = args.template === undefined @@ -1466,12 +1483,22 @@ class Tools { ["channel_type", type], ...(about ? [["about", about]] : []), ...(ttl ? [["ttl", String(ttl)]] : []), + // As Buzz starts a Solo chat: the relay adds the `p` members in the create. + ...(solo ? channelLabelTags([SOLO_LABEL, SOLO_NAMED_LABEL]) : []), + ...members.map((key) => ["p", key]), ], }); - const shown = await until(() => this.metadata(id), Boolean).catch(unread); + const labeled = (now: BuzzEvent | undefined) => + !!now && (!solo || soloChannelMetadata(now as RelayEvent)); + const shown = await until(() => this.metadata(id), labeled).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" : ""}${role === "guest" ? "; join_role was not set" : ""}.`; + 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" : ""}${members.length ? "; its members are not confirmed" : ""}.`; const lines = [`created ${id}`]; + if (!labeled(shown)) + lines.push( + "The community did not keep the Solo label, so it is an ordinary private channel.", + ); + if (members.length) lines.push(...(await this.joined(id, members))); if (template) lines.push(...(await this.apply(id, template))); // Last, so the template's agents join as members, not guests. if (role === "guest") @@ -1486,6 +1513,25 @@ class Tools { return lines.join("\n"); } + /** Reports each of a new channel's `members` as its roster shows them. */ + async joined(id: string, members: readonly string[]) { + const roster = await until( + () => this.roster(id), + (now) => members.every((key) => now.some((m) => m.pubkey === key)), + ).catch(unread); + const names = await this.names(members).catch( + () => new Map(), + ); + return members.map((key) => { + const who = names.has(key) ? `${key} ${names.get(key)}` : key; + if (roster instanceof Error) + return `${who}: the community accepted it but ${roster.message}`; + return roster.some((m) => m.pubkey === key) + ? `added ${who}` + : `${who}: the community accepted it but does not show it yet`; + }); + } + /** Starts a new channel's Canvas and adds its agents from `template`. */ async apply(id: string, template: ChannelTemplate) { const lines: string[] = []; From 1268074b4f2d46daaa0bdd20d61bf6c424ea6cba Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Sun, 1 Nov 2026 22:19:02 -0800 Subject: [PATCH 2/3] fix(mcp): refuse a Solo template and check members without solo first A Solo chat's members are only the ones the caller names, so solo with a template is refused. members without solo now reports that before parsing the keys. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- src/buzz-mcp/tools.test.ts | 10 +++++++--- src/buzz-mcp/tools.ts | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 342dd93ba7..0572ab6152 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1563,15 +1563,19 @@ it("refuses Solo arguments that do not fit, before publishing", async () => { visibility: "private", ...args, }); - const shape = "A Solo chat is a private stream without about."; + const shape = "A Solo chat is a private stream without about or template."; await expect(create({ solo: true, visibility: "open" })).rejects.toThrow( shape, ); await expect(create({ solo: true, type: "forum" })).rejects.toThrow(shape); await expect(create({ solo: true, about: "notes" })).rejects.toThrow(shape); - await expect(create({ members: [alice] })).rejects.toThrow( - "members is for Solo chats.", + await expect(create({ solo: true, template: "Triage" })).rejects.toThrow( + shape, ); + for (const members of [[alice], ["alice"], alice]) + await expect(create({ members })).rejects.toThrow( + "members is for Solo chats.", + ); await expect(create({ solo: true, members: ["alice"] })).rejects.toThrow( "Not a pubkey: alice", ); diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index a6b18b6a8e..f89756429a 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -1458,13 +1458,22 @@ class Tools { if (args.solo !== undefined && typeof args.solo !== "boolean") throw new Error("solo must be true or false."); const solo = args.solo === true; + if (!solo && args.members !== undefined) + throw new Error("members is for Solo chats."); const members = keys(args, "members").filter( (key) => key !== this.client.pubkey, ); - if (!solo && args.members !== undefined) - throw new Error("members is for Solo chats."); - if (solo && (args.visibility !== "private" || type !== "stream" || about)) - throw new Error("A Solo chat is a private stream without about."); + // Its members are only the ones named, so no template adds agents. + if ( + solo && + (args.visibility !== "private" || + type !== "stream" || + about || + args.template !== undefined) + ) + throw new Error( + "A Solo chat is a private stream without about or template.", + ); if (members.length > 32) throw new Error("A Solo chat starts with at most 32 members."); // Found first, so a wrong name creates nothing. From 80f58e0efcbc0bf04ba532b0a5a16189e26a6953 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Mon, 2 Nov 2026 09:07:28 -0800 Subject: [PATCH 3/3] test(mcp): cover a Solo create at the 32-member limit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- src/buzz-mcp/tools.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 0572ab6152..ce81dbae4f 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -1594,6 +1594,22 @@ it("refuses Solo arguments that do not fit, before publishing", async () => { expect(published).toEqual([]); }); +it("starts a Solo chat with the relay's maximum of 32 members", async () => { + const { client, published } = relay(); + const members = Array.from({ length: 32 }, (_, n) => + (n + 1).toString(16).padStart(64, "0"), + ); + await callTool(client, {}, "channel_create", { + name: "x", + visibility: "private", + solo: true, + members: [...members, me], + }); + expect( + published[0]?.tags.filter(([key]) => key === "p").map(([, key]) => key), + ).toEqual(members); +}); + it("creates no Solo chat when a member refuses, and says what it cannot confirm", async () => { const { client, published, state } = relay({ [alice]: "blocked: this agent only joins its owner's channels",