Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ same local bound and lifetime.
Keys stay in native custody. Hosted-provider provisioning and standalone tool
servers do not support these operations.

## Message edits

The shared MCP `edit` tool retains the latest live attachment set, including an
empty set left by a previous edit. Both app-hosted and standalone agents resolve
that state as the agent through the version-1 `effective_edit` primary lookup on
`/query`; they verify the signed original/replacement and their target/channel.
The exact filter uses kinds `[9,40002,40008,45001,45003]`.
Deploy the relay support from
[buzz#9226](https://github.com/block/buzz/pull/9226) before releasing this client.
An old relay's array response, inaccessible target, invalid response or failed
lookup aborts without publishing. All MCP agent edits stop working on a community
whose relay lacks this lookup; upgrade each community before releasing the client.
There is no original-attachment fallback. Observe MCP edit errors and the relay's
`buzz_media_publication_access_total` denials during rollout. Rolling the client
back restores the original-attachment resurrection bug.

The tool waits until the second after the observed head before requesting a
signature, reusing the bounded wait for notes/canvas. If that second is over five
seconds ahead, it asks the caller to retry when the clock catches up.
The MCP publisher stamps the current time, so its five-second wait bounds tool
latency. The CLI supplies an explicit timestamp and tolerates a 60-second lead.
Concurrent writers still use relay ordering; this is not compare-and-swap. The relay must
authorize every carried file for the signing agent; an owner's access does not
grant the agent private-media access, and denial never causes an automatic retry
with files stripped.

## External app authorization

The build flag `BUZZ_EXTERNAL_MCP_COMMUNITIES` lists the allowed community
Expand Down
4 changes: 4 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,10 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents.
standalone Buzz MCP server checks that channel's members and privacy against
the key its relay advertises; where the relay does not advertise one, the
tools find none.
- Agent text edits retain the message's current attachments, including removals
made by an earlier edit. Editing requires a community that supports effective
edit lookup; if that read fails, the tool leaves the message alone. The agent
must still have permission to publish every retained file.
- The community authorizes reads and writes. An agent's name, profile, picture,
or appearance in a list does not prove access, ownership, or running status.
When helping someone explore a community, use only your available access and
Expand Down
1 change: 1 addition & 0 deletions src/bundled/bestie/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ export async function bestieFixture(
publish: vi.fn(),
publishProfile: vi.fn(),
query: vi.fn(),
effectiveEdit: vi.fn(),
searchProfiles: vi.fn(),
upload: vi.fn(),
media: vi.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/bundled/builderlab/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function provideAgents(
publish: vi.fn(),
publishProfile: vi.fn(),
query: vi.fn(),
effectiveEdit: vi.fn(),
searchProfiles: vi.fn(),
upload: vi.fn(),
media: vi.fn(),
Expand Down
24 changes: 24 additions & 0 deletions src/buzz-mcp/app-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,30 @@ it("carries connection authorities independently of query event authors", async
expect(query).toHaveBeenCalledWith([{ kinds: [39000] }]);
});

it("reads effective edit state as the agent and withholds it after the turn retires", async () => {
const result = gate<Awaited<ReturnType<AgentHandle["effectiveEdit"]>>>();
const effectiveEdit = vi.fn(() => result.promise);
let live = true;
const client = appClient({ effectiveEdit } as unknown as AgentHandle, {
read: vi.fn(),
memories: async () => ({ entries: [], partial: false }),
check: () => {
if (!live) throw new Error("retired");
},
});
const pending = client.effectiveEdit?.("target", "private");
expect(effectiveEdit).toHaveBeenCalledWith("target", "private");
live = false;
result.resolve({ id: "head" } as Awaited<
ReturnType<AgentHandle["effectiveEdit"]>
>);
await expect(pending).rejects.toThrow("retired");
await expect(client.effectiveEdit?.("target", "private")).rejects.toThrow(
"retired",
);
expect(effectiveEdit).toHaveBeenCalledOnce();
});

it("does not invent authority when a host has none", () => {
const client = appClient({ pubkey: "agent" } as AgentHandle, {
read: vi.fn(),
Expand Down
6 changes: 6 additions & 0 deletions src/buzz-mcp/app-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ export function appClient(
check();
return events;
},
effectiveEdit: async (id, channel) => {
check();
const event = await agent.effectiveEdit(id, channel);
check();
return event;
},
searchProfiles: (query, limit) => {
check();
return agent.searchProfiles(query, limit);
Expand Down
2 changes: 2 additions & 0 deletions src/buzz-mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export interface BuzzClient {
readonly signingAuthority?: string | undefined;
/** Reads the community as the agent. Every filter names its kinds. */
query(filters: readonly Filter[]): Promise<readonly BuzzEvent[]>;
/** Latest live attachment state, read as this agent from a primary snapshot. */
effectiveEdit?(id: string, channel: string): Promise<BuzzEvent>;
/** Finds profiles whose `name` or `display_name` matches `query`, best
* match first. `undefined` when the community has no profile name search;
* NIP-50 search, which matches the whole profile, is the fallback. */
Expand Down
226 changes: 226 additions & 0 deletions src/buzz-mcp/edit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { afterEach, expect, it, vi } from "vitest";
import { bytesToHex } from "nostr-tools/utils";
import { eventDto } from "../features/relay/events";
import { keypair, signed } from "../features/relay/testing";
import { nodeClient } from "./node";
import { callTool } from "./tools";
import type { BuzzEvent } from "./client";

const agent = keypair();
const owner = keypair();
const channel = "00000000-0000-4000-8000-000000000001";
const file = [
"imeta",
"url https://relay.example.org/media/private.png",
`x ${"a".repeat(64)}`,
];

function fixture(kind = 9) {
vi.useFakeTimers();
vi.setSystemTime(1_800_000_000_000);
const original = signed(agent, {
kind,
content: "original",
tags: [["h", channel], file],
created_at: 1_799_999_999,
});
let latest: BuzzEvent | null = null;
let answer: unknown;
let lookupStatus = 200;
let accepted = true;
const published: BuzzEvent[] = [];
const fetcher = vi.fn(async (url: string, init: RequestInit) => {
const body = JSON.parse(String(init.body));
if (url.endsWith("/events")) {
const event = eventDto(body);
published.push(event);
if (accepted) latest = event;
return Response.json({
accepted,
message: accepted ? "" : "file not publishable by signer",
});
}
const auth = String(
(init.headers as Record<string, string>).authorization,
).slice(6);
expect(
eventDto(JSON.parse(Buffer.from(auth, "base64").toString())).pubkey,
).toBe(agent.pubkey);
if (body[0].effective_edit) {
expect(body).toEqual([
{
kinds: [9, 40002, 40008, 45001, 45003],
ids: [original.id],
"#h": [channel],
effective_edit: 1,
consistency: "strong",
},
]);
return Response.json(
answer === undefined
? { effective_edit: 1, original, edit: latest }
: answer,
{ status: lookupStatus },
);
}
return Response.json([original]);
});
vi.stubGlobal("fetch", fetcher);
const client = nodeClient({
relay: "wss://relay.example.org",
key: bytesToHex(agent.secret),
auth: JSON.stringify(["auth", owner.pubkey, "", "0".repeat(128)]),
root: "/",
});
return {
original,
client,
published,
fetcher,
edit: () =>
callTool(client, { channel }, "edit", {
id: original.id,
text: "edited",
}),
setLatest: (tags: string[][], created_at = 1_800_000_000) => {
latest = signed(agent, {
kind: 40003,
content: "prior",
tags: [
["h", channel],
["e", "invalid"],
["e", original.id.toUpperCase()],
...tags,
],
created_at,
});
return latest;
},
answer: (value: unknown, status = 200) => {
answer = value;
lookupStatus = status;
},
refuse: () => {
accepted = false;
},
};
}

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it.each([9, 40002, 40008, 45001, 45003])(
"retains current private attachments across successive MCP edits of kind %s",
async (kind) => {
const f = fixture(kind);
await f.edit();
expect(f.published[0]?.tags).toContainEqual(file);
const replacement = [
"imeta",
"url https://relay.example.org/media/replacement.png",
`x ${"b".repeat(64)}`,
];
const head = f.setLatest([replacement]);
const second = f.edit();
await vi.advanceTimersByTimeAsync(1000);
await second;
expect(f.published[1]?.created_at).toBeGreaterThan(head.created_at);
expect(f.published[1]?.tags.filter((t) => t[0] === "imeta")).toEqual([
replacement,
]);
f.setLatest([], 1_800_000_001);
const third = f.edit();
await vi.advanceTimersByTimeAsync(1000);
await third;
const fourth = f.edit();
await vi.advanceTimersByTimeAsync(1000);
await fourth;
expect(
f.published.slice(2).map((e) => e.tags.filter((t) => t[0] === "imeta")),
).toEqual([[], []]);
expect(
f.published.every(
(e) =>
e.pubkey === agent.pubkey &&
e.tags.some((t) => t[0] === "auth" && t[1] === owner.pubkey),
),
).toBe(true);
},
);

it.each([
["old relay array", [], 200],
["missing edit", { effective_edit: 1 }, 200],
["wrong version", { effective_edit: 2, edit: null }, 200],
["404", { error: "message not found" }, 404],
["lookup failure", { error: "database unavailable" }, 503],
])(
"aborts before message signing/publication on %s",
async (_, response, status) => {
const f = fixture();
f.answer(response, status as number | undefined);
await expect(f.edit()).rejects.toThrow();
expect(f.published).toEqual([]);
expect(
f.fetcher.mock.calls.every(([url]) => !url.endsWith("/events")),
).toBe(true);
},
);

it.each([
"target",
"channel",
"kind",
"signature",
"replacement target",
"replacement channel",
"replacement kind",
])("rejects a signed snapshot with invalid %s", async (fault) => {
const f = fixture();
let original = f.original;
let edit: BuzzEvent | null = null;
if (fault === "target")
original = signed(agent, { ...original, content: "another" });
if (fault === "channel")
original = signed(agent, { ...original, tags: [["h", "elsewhere"]] });
if (fault === "kind") original = signed(agent, { ...original, kind: 7 });
if (fault === "signature") original = { ...original, sig: "0".repeat(128) };
if (fault.startsWith("replacement"))
edit = signed(agent, {
kind: fault === "replacement kind" ? 7 : 40003,
content: "bad",
tags: [
["h", fault.endsWith("channel") ? "elsewhere" : channel],
["e", fault.endsWith("target") ? "f".repeat(64) : original.id],
],
});
f.answer({ effective_edit: 1, original, edit });
await expect(f.edit()).rejects.toThrow();
expect(f.published).toEqual([]);
});

it("refuses an excessively future head instead of extending its timeline", async () => {
const f = fixture();
f.setLatest([file], 1_800_000_010);
await expect(f.edit()).rejects.toThrow(/clock catches up/);
expect(f.published).toEqual([]);
});

it.each([40003, 7, 45002])(
"rejects unsupported original kind %s without publication",
async (kind) => {
const f = fixture(kind);
await expect(f.edit()).rejects.toThrow("wrong original");
expect(f.published).toEqual([]);
},
);

it("reports signer media denial without retrying or stripping the attachments", async () => {
const f = fixture();
f.refuse();
await expect(f.edit()).rejects.toThrow("file not publishable by signer");
expect(f.published).toHaveLength(1);
expect(f.published[0]?.tags).toContainEqual(file);
});
14 changes: 14 additions & 0 deletions src/buzz-mcp/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { hexToBytes } from "nostr-tools/utils";
import { CANVAS_MAX_EVENT_BYTES } from "../features/channel-templates/canvas-limits.ts";
import { eventsDto } from "../features/relay/events";
import {
effectiveEditDto,
effectiveEditFilter,
} from "../features/relay/effective-edit";
import type {
BuzzClient,
BuzzEvent,
Expand Down Expand Up @@ -166,6 +170,16 @@ export function nodeClient(
key: pubkey,
query: async (filters) =>
eventsDto(await request("/query", JSON.stringify(filters))),
async effectiveEdit(id, channel) {
return effectiveEditDto(
await request(
"/query",
JSON.stringify(effectiveEditFilter(id, channel)),
),
id,
channel,
);
},
async searchProfiles(query, limit) {
const response = await send(
"/profiles/search",
Expand Down
17 changes: 17 additions & 0 deletions src/buzz-mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ function fake(
const client: BuzzClient = {
key: me,
pubkey: me,
effectiveEdit: vi.fn(async (id: string) => {
const found = events.find((event) => event.id === id);
if (!found) throw new Error("No effective edit fixture");
return found;
}),
query: vi.fn(async (filters: readonly Filter[]) => {
const newest = [...events, ...kept].sort(
(a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id),
Expand Down Expand Up @@ -623,6 +628,18 @@ it("posts at the channel's top level when given a channel", async () => {
expect(published[0]?.tags).toEqual([["h", "other"]]);
});

it("refuses to edit when the host cannot obtain effective attachment state", async () => {
const { client, published } = fake(community);
delete client.effectiveEdit;
await expect(
callTool(client, { channel: "chan" }, "edit", {
id: root,
text: "must not copy the original's attachments",
}),
).rejects.toThrow("cannot obtain effective edit state");
expect(published).toEqual([]);
});

it("tags the community emoji a message or edit uses, as the composer does", async () => {
const stamp = "https://relay/media/stamp.png";
const emojiSet = (pubkey: string, created_at: number, entries: string[][]) =>
Expand Down
Loading
Loading