https://github.com/m5d215/agent-salon
Lightweight HTTP-to-MCP notification bridge for Claude Code
https://github.com/m5d215/agent-salon
axum claude-code mcp notifications rust
Last synced: about 1 month ago
JSON representation
Lightweight HTTP-to-MCP notification bridge for Claude Code
- Host: GitHub
- URL: https://github.com/m5d215/agent-salon
- Owner: m5d215
- License: mit
- Created: 2026-04-04T21:58:08.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-21T03:48:48.000Z (2 months ago)
- Last Synced: 2026-04-21T05:35:23.951Z (2 months ago)
- Topics: axum, claude-code, mcp, notifications, rust
- Language: Rust
- Homepage:
- Size: 133 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# agent-salon
A gathering place for Claude Code sessions. Multiple sessions — each running under a different role/persona — register with a label and talk to each other (or broadcast) through `notifications/claude/channel`. External processes can also drop messages in via a simple HTTP webhook.
agent-salon runs as a standalone long-running daemon that serves both the MCP Streamable HTTP transport (`/mcp`) and an external webhook (`/notify`) on a single port.
## Requirements
- Rust 1.70+
- Claude Code with `channelsEnabled` setting
## Build
```bash
cargo build --release
```
## Setup
### 1. Start agent-salon (daemon)
```bash
./target/release/agent-salon
# → listening on http://127.0.0.1:9315
```
Keep it running in a separate terminal / tmux pane / launchd job. One daemon per host is enough; every session on every machine that can reach the host uses the same salon.
### 2. Register as MCP server (HTTP transport)
Each session you want to invite picks its own label and puts it on the `/mcp` URL:
```bash
claude mcp add --scope project --transport http agent-salon 'http://127.0.0.1:9315/mcp?label=laptop-a'
```
Or write `.mcp.json` directly:
```json
{
"mcpServers": {
"agent-salon": {
"type": "http",
"url": "http://127.0.0.1:9315/mcp?label=laptop-a"
}
}
}
```
`?label=` is how this session names itself to the rest of the salon. Pick something meaningful per project/role.
### 3. Enable channel notifications
**Both are required.** Channel notifications are off by default in Claude Code.
Add to your settings file (`~/.claude/settings.json` or `.claude/settings.local.json`):
```json
{
"channelsEnabled": true
}
```
### 4. Start Claude Code with channel flags
```bash
claude --dangerously-load-development-channels server:agent-salon
```
The `--dangerously-load-development-channels` flag is needed for non-plugin MCP servers. Without it, the server will be rejected as "not on the approved channels allowlist".
## Usage
There are two ways to drop a message into the salon:
1. **From inside a Claude Code session** — call the `send_message` MCP tool. Schema-validated, no URL construction, and the sender identity is bound to the session's own label (no spoofing possible).
2. **From an external process** (CI hook, shell script, webhook) — POST to `/notify` with a `?label=` query parameter.
### MCP tool: `send_message`
Any connected session that was initialized with `?label=` can call:
```jsonc
// tools/call arguments
{
"content": "Build finished", // required
"target": "laptop-a", // optional; omit to broadcast
"meta": { "commit": "abc123" } // optional; each key becomes a attribute
}
```
The sender (`source`) is taken from the calling session's own `?label=` and cannot be overridden from the tool arguments. A session without a label receives `-32602 Invalid Params` if it tries to call `send_message`.
### POST /notify?label=<name>
The sender's identity lives in the URL (`?label=`), **not in the body**. This is deliberate: the body is usually produced by an LLM or an automated process, and a body-declared `source` would let the payload spoof its own identity. Putting the label on the URL pushes identification into the transport layer, which is controlled by the calling environment (shell config, `.mcp.json`, CI secrets, etc.).
| Location | Field | Type | Required | Description |
|----------|-------|------|----------|-------------|
| query | `label` | string | **yes** | Sender identifier. Surfaced to the receiver as ``. |
| body | `content` | string | yes | Message body |
| body | `target` | string | no | Session label to deliver to. If omitted, the notification is broadcast to every connected session. |
| body | `meta` | object | no | Arbitrary key-value metadata. Every key is passed through to the channel tag as an attribute. |
`source` in the body is **ignored** (silently stripped). Use the query parameter.
**Responses:**
- `202 Accepted` — notification queued for delivery
- `400 Bad Request` — `?label=` missing
- `422 Unprocessable Entity` — missing or invalid body
If no session matches the target (or no session is connected at all), the message is dropped silently.
```bash
# External process addressing a specific session.
curl -X POST 'http://127.0.0.1:9315/notify?label=ci' \
-H 'Content-Type: application/json' \
-d '{"content":"Build finished","target":"laptop-a"}'
```
### Labelling sessions
A label is identity, not a group key — only one connection can hold a given label at a time. Reconnecting with a label already in use (after Claude Code's `/clear`, or when a `claude -p` one-shot uses the same label as an interactive session) evicts the prior owner; the older session stops receiving messages. Pick distinct labels for sessions that need to coexist. Unlabeled sessions only receive broadcasts (notifications without `target`) and cannot call `send_message`.
### MCP tools
| Tool | Description |
|------|-------------|
| `salon_status` | Show HTTP endpoints, active sessions with labels, and message count. |
| `send_message` | Deliver a channel notification to another session (or broadcast). |
### Admin UI
`GET /admin` renders a plain HTML page listing every persisted message. Filter by `source` / `target` / time range, page through history, click a row for full detail (content, full `meta` JSON, `delivered_to`, `delivery_errors`, `sender_addr`, `sender_session_id`).
The UI has no authentication — it relies on the surrounding network layer (default bind is `0.0.0.0`, so restrict exposure via firewall or a Tailscale / VPN ACL; set `AGENT_SALON_BIND=127.0.0.1` to keep it loopback-only).
### Persistence
Every `deliver_notification` call writes a row into a SQLite database (default `./agent-salon.db`). Schema:
```sql
CREATE TABLE messages (
id TEXT PRIMARY KEY, -- UUID v7 (time-sortable)
ts TEXT NOT NULL, -- ISO 8601
via TEXT NOT NULL, -- 'notify' | 'tool'
source TEXT NOT NULL, -- sender label
target TEXT, -- NULL for broadcast
content TEXT NOT NULL,
meta TEXT NOT NULL, -- JSON
delivered_to TEXT NOT NULL, -- JSON array of labels that received it
delivery_errors TEXT NOT NULL, -- JSON array of labels that failed and were pruned
sender_addr TEXT, -- remote addr of POST /notify (NULL for tool sends)
sender_session_id TEXT -- MCP session id (NULL for /notify)
);
```
No retention policy — the table accumulates. Rotate manually when needed.
### Configuration
| Env var | Default | Description |
|---------|---------|-------------|
| `AGENT_SALON_PORT` | `9315` | TCP port the daemon binds to |
| `AGENT_SALON_BIND` | `0.0.0.0` | Bind address. Default accepts connections on every interface (agent-salon has no auth — rely on a firewall or Tailscale / VPN ACL). Set to `127.0.0.1` to restrict to loopback. |
| `AGENT_SALON_DB` | `./agent-salon.db` | SQLite database path. Created on first run. |
| `AGENT_SALON_ALIASES` | `` | Comma-separated `alias:real_label` pairs. When a sender specifies `target: `, the daemon routes to sessions labelled `` instead. Useful when a sender runs in a censored / observed environment and the real target label should not appear in the sender's `.mcp.json`, conversation, or logs. Aliases take precedence over real labels of the same name. |
| `AGENT_SALON_ALLOWED_HOSTS` | `` (use rmcp default: `localhost,127.0.0.1,::1`) | Comma-separated `host` or `host:port` authorities allowed in the inbound `Host` header. The MCP transport (`rmcp`) rejects mismatching hosts with `403 Forbidden` to mitigate DNS rebinding. When clients reach the daemon over a Tailnet / VPN / reverse proxy hostname (anything other than loopback), list those names here. Empty value keeps the rmcp default. |
| `AGENT_SALON_CONFIG` | `` (no config file) | Optional path to a `KEY=VALUE` config file. Useful when the daemon runs under a process supervisor (`brew services`, `systemd`, `launchd`) where injecting host-specific env vars is awkward — point this at a file the supervisor can keep stable, and edit values there. Process env always wins over the file. See [Config file](#config-file) below. |
### Config file
When `AGENT_SALON_CONFIG` points at an existing file, agent-salon reads it on startup and uses each `KEY=VALUE` line as a fallback for the same-named env var. The live process environment always takes precedence — the file just fills in keys that are not already set.
Format:
- One `KEY=VALUE` per line
- Lines starting with `#` and blank lines are ignored
- Surrounding double quotes around the value (`KEY="value"`) are stripped — useful when the value contains commas or `=`
- Keys without `=` and lines with an empty key are silently skipped
```ini
# /opt/homebrew/etc/agent-salon.conf
AGENT_SALON_BIND=0.0.0.0
AGENT_SALON_ALLOWED_HOSTS="my-host.tailXXXXXX.ts.net,localhost,127.0.0.1"
AGENT_SALON_ALIASES="notes:laptop-a,drafts:home-mac"
```
The Homebrew formula sets `AGENT_SALON_CONFIG=${HOMEBREW_PREFIX}/etc/agent-salon.conf` by default, so editing that file (and `brew services restart agent-salon`) is enough to apply host-specific settings without touching the generated `launchd` plist.
### Target aliases
`AGENT_SALON_ALIASES` lets a sender refer to a target under an innocuous cover name. Example:
```bash
AGENT_SALON_ALIASES='notes:laptop-a,drafts:home-mac' ./target/release/agent-salon
```
A sender can then write:
```jsonc
send_message({ content: "ping", target: "notes" }) // routed to sessions labelled "laptop-a"
```
Only `target` is resolved — `source` is never rewritten. Resolution happens before persistence, so the `target` column in the DB always holds the real label; the fact that a sender used an alias is not recorded, and admin UI filters (`target`, `participant_*`) work on real labels uniformly.
## Local testing
Without Claude Code, you can exercise the full pipeline standalone:
```bash
./scripts/test-server.sh
```
The script spins up agent-salon, runs through initialize / initialized / GET stream, POSTs a sample notification, and prints the resulting `notifications/claude/channel` event.
## Architecture
```
External Process agent-salon (daemon) Claude Code
| | |
| POST /notify?label=X (HTTP) | |
|--------------------------------->| |
| 202 Accepted | |
|<---------------------------------| |
| | notifications/claude/channel |
| | (MCP Streamable HTTP / SSE) |
| |-------------------------------->|
| | | (wakes session)
```
Internally, each connected Claude Code session is tracked as a `Session { peer, label }`. Delivery filters by label (or fans out on broadcast). When a new session initializes with a label already held by another session, the prior session is evicted from the registry on the spot; sessions whose channel closed without a same-label reconnect are pruned lazily on the next send failure.
## Tech Stack
- **Rust** with `rmcp` (official MCP SDK), `axum`, `tokio`
- MCP Streamable HTTP server (`rmcp::transport::streamable_http_server`)
- Single binary, long-running daemon