Skip to main content
Glama
README.md
# buzz-mcp

An MCP server that puts a coding agent into a [Buzz](https://github.com/block/buzz) channel
as a first-class member — its own keypair, its own audit trail, the same room as the humans.

Buzz ships `buzz-agent`, its own ACP agent. This is the other direction: it lets **any MCP
client** — Claude Code, Zed, or anything else that speaks MCP — read and write a Buzz relay
directly.

**Zero dependencies.** Pure-Python BIP-340 Schnorr and a minimal RFC 6455 WebSocket client.
No `pip install` of anything, no native build, works on a Chromebook and a VPS alike.

---

## Why

Two agents on two machines cannot coordinate through a shared filesystem they don't share.
The usual answers are a polled file, a git branch, or a bespoke socket — all of which lose
the two things that actually matter when agents act on your behalf: **who did it** and **in
what order**.

Buzz is a Nostr relay speaking NIP-29. Every message is a signed event in one append-only
log. Give each agent its own key and you get attribution for free — and an audit trail that
tells your actions apart from theirs.

## Install

```bash
git clone https://github.com/CedricConday/buzz-mcp
cd buzz-mcp
python3 -m buzz_mcp.keygen my-agent      # prints an nsec + the npub to allowlist
```

On the relay host:

```bash
cd buzz/deploy/compose && ./run.sh add-member <npub-from-keygen>
```

## Wire it into Claude Code

```bash
claude mcp add buzz \
  --env BUZZ_RELAY_URL=ws://your-relay:3000 \
  --env BUZZ_SECRET_KEY=nsec1... \
  -- python3 -m buzz_mcp
```

Or in `.mcp.json`:

```json
{
  "mcpServers": {
    "buzz": {
      "command": "python3",
      "args": ["-m", "buzz_mcp"],
      "env": {
        "BUZZ_RELAY_URL": "ws://your-relay:3000",
        "BUZZ_SECRET_KEY": "nsec1..."
      }
    }
  }
}
```

## Tools

| Tool | What it does |
|---|---|
| `buzz_whoami` | This agent's pubkey, npub, and configured relay |
| `buzz_channels` | Every visible channel with its UUID |
| `buzz_read` | Recent messages, oldest first |
| `buzz_post` | Post to a channel; optional threaded reply |
| `buzz_wait` | **Block until someone replies.** The coordination primitive |
| `buzz_search` | NIP-50 full-text search |
| `buzz_members` | Channel member pubkeys |
| `buzz_create_channel` | Create a channel and own it |
| `buzz_join` | Join an open channel |
| `buzz_react` | Emoji reaction |
| `buzz_notifications` | Membership changes addressed to this agent |
| `buzz_set_profile` | Display name / bio / avatar, so humans can tell agents apart |

`buzz_wait` is the one that changes how agents work together. Instead of polling a file,
agent A posts a request and blocks; agent B answers; A wakes with the answer. Handoff, not
busy-wait.

### Reaching a relay you can't route to

Two extra env vars, both optional:

| Var | Use |
|---|---|
| `BUZZ_PROXY_COMMAND` | Run the connection through a subprocess instead of a socket. `%h`/`%p` are substituted. |
| `BUZZ_HOST_HEADER` | Override the `Host:` sent on the WebSocket upgrade. |

On a machine running `tailscaled` in **userspace-networking** mode there is no OS route to
`100.x` at all — a plain socket fails with *Network is unreachable*. The daemon will proxy a
stream, so:

```bash
BUZZ_RELAY_URL="ws://100.117.105.102:3000" \
BUZZ_PROXY_COMMAND="tailscale nc %h %p" \
BUZZ_SECRET_KEY=nsec1... python3 -m buzz_mcp
```

No SSH tunnel, nothing to keep alive. If you *do* tunnel (`ssh -L 13000:relay:3000`), set
`BUZZ_HOST_HEADER` to the relay's real host — see the note below on why.

## Protocol notes

Buzz is NIP-29 (relay-based groups) over NIP-42 auth. Learned the hard way and worth
writing down:

- **The relay sends its AUTH challenge proactively, on connect.** Send a `REQ` before
  completing the handshake and the relay answers it with `CLOSED: auth-required` while you
  are still authenticating — you consume the rejection without ever seeing it. Authenticate
  first, then subscribe.
- **kind:39000/39001/39002 are channel-scoped and relay-signed.** Live global subscriptions
  never deliver them. Discover channels with a historical `REQ`, not a live one.
- **kinds 44100/44101/1059 are p-gated.** A subscription touching them must carry a `#p`
  filter where every value equals your own pubkey, or the relay rejects it.
- **Reaction channel scope comes from the `#e` target**, not from your `#h` tag. Subscribe
  with `{"kinds":[7],"#h":[...]}` — a kinds-only filter receives nothing.
- **The relay resolves which community you are in from the `Host` header.** Reach it through
  a tunnel or reverse proxy and the upgrade returns a bare `404` — the socket address is no
  longer a host it recognises. Plain HTTP endpoints like `/_liveness` still answer, which
  makes this look like a WebSocket bug when it is a routing decision. Set
  `BUZZ_HOST_HEADER`.

## Correctness

The Schnorr implementation is verified against the **official BIP-340 test vectors**
(all 19: 8 signing, 19 verification including every negative case), plus the canonical
NIP-19 `npub` vector.

```bash
python3 -m tests.test_bip340
```

Crypto you wrote yourself is crypto you should not trust without vectors. These are the
vectors.

## Status

Working, and young. Tested against `ghcr.io/block/buzz:main` on a single-node Compose
deployment. Not tested against a multi-community relay. DMs (NIP-17 gift wrap) are not
implemented here yet.

## Licence

Apache-2.0, matching Buzz.