Purpose: Set up Mercury (inter-agent message bus) and its Claude Code MCP plugin on a new machine. This is a self-contained guide an agent can follow from scratch.
Source repo: https://github.com/gudnuf/mercury
Run these commands and note the results. Adapt later steps accordingly.
# OS detection
uname -s # Darwin = macOS, Linux = Linux
uname -m # x86_64, aarch64/arm64
# NixOS check
[[ -f /etc/NIXOS ]] && echo "NixOS" || echo "Not NixOS"
# Package managers
command -v nix && echo "Nix available"
command -v brew && echo "Homebrew available"
command -v apt && echo "apt available"
# Required tools
command -v go && go version
command -v bun && bun --version
command -v claude && claude --version
command -v jq && echo "jq available"
command -v tmux && echo "tmux available"Requirements:
- Go (to build Mercury CLI) -- or Nix (preferred, builds from flake)
- Bun (to run the MCP plugin -- it uses
bun:sqlite) - Claude Code 2.x+ with plugin/channel support
- jq (used by launch scripts)
- tmux (for running agents in sessions)
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# Nix (ephemeral)
nix shell nixpkgs#bun# macOS
brew install go
# Debian/Ubuntu
sudo apt install golang-go
# Nix (ephemeral)
nix shell nixpkgs#go# macOS
brew install jq tmux
# Debian/Ubuntu
sudo apt install jq tmux
# Nix
nix shell nixpkgs#jq nixpkgs#tmuxChoose ONE method based on your environment.
# One-shot build
nix build github:gudnuf/mercury
# Binary at ./result/bin/mercury
# Or install to profile (persistent)
nix profile install github:gudnuf/mercury
# Or add as a flake input in your system config:
# mercury.url = "github:gudnuf/mercury";
# environment.systemPackages = [ inputs.mercury.packages.${system}.default ];git clone https://github.com/gudnuf/mercury.git /tmp/mercury-build
cd /tmp/mercury-build
go build -o mercury ./cmd/mercury
# Move to a directory on your PATH
sudo mv mercury /usr/local/bin/
# Or: mv mercury ~/.local/bin/ (if ~/.local/bin is on PATH)
# Or: mv mercury ~/forge/tools/ (if ~/forge/tools is on PATH)cd /tmp
git clone https://github.com/gudnuf/mercury.git
cd mercury
nix develop # drops you into a shell with Go + SQLite
go build -o mercury ./cmd/mercury
mv mercury ~/.local/bin/mercury --help
# Should show: send, read, subscribe, channels, log, etc.
# Test it works (creates DB at ~/.local/share/mercury/mercury.db)
mercury send --as "setup-test" --to "status" "mercury installed"
mercury log --channel status
# Should show the message you just sentThe MCP plugin bridges Mercury into Claude Code as push notifications. It polls the SQLite DB and delivers messages via notifications/claude/channel.
FORGE_BASE="${FORGE_BASE:-$HOME/forge}"
MARKETPLACE="$FORGE_BASE/marketplace"
PLUGIN_DIR="$MARKETPLACE/plugins/mercury"
mkdir -p "$PLUGIN_DIR/.claude-plugin"
mkdir -p "$MARKETPLACE/.claude-plugin"cat > "$MARKETPLACE/.claude-plugin/marketplace.json" << 'MARKETPLACE_EOF'
{
"name": "forge-local",
"owner": {
"name": "forge"
},
"plugins": [
{
"name": "mercury",
"description": "Mercury inter-agent message bus channel for Claude Code — push notifications for multi-agent coordination.",
"category": "productivity",
"source": "./plugins/mercury"
}
]
}
MARKETPLACE_EOFcat > "$PLUGIN_DIR/.claude-plugin/plugin.json" << 'PLUGIN_EOF'
{
"name": "mercury",
"description": "Mercury inter-agent message bus channel for Claude Code — push notifications for multi-agent coordination.",
"version": "0.0.1",
"keywords": [
"mercury",
"messaging",
"channel",
"mcp",
"agents"
]
}
PLUGIN_EOFThis tells Claude Code how to launch the MCP server. The ${CLAUDE_PLUGIN_ROOT} variable is expanded by Claude Code at runtime to point at the plugin's install directory.
cat > "$PLUGIN_DIR/.mcp.json" << 'MCP_EOF'
{
"mcpServers": {
"mercury": {
"command": "bun",
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--silent", "start"]
}
}
}
MCP_EOFcat > "$PLUGIN_DIR/package.json" << 'PKG_EOF'
{
"name": "claude-channel-mercury",
"version": "0.0.1",
"license": "MIT",
"type": "module",
"bin": "./server.ts",
"scripts": {
"start": "bun install --no-summary && bun server.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
}
}
PKG_EOFcat > "$PLUGIN_DIR/tsconfig.json" << 'TS_EOF'
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"types": ["bun-types"]
},
"include": ["*.ts"]
}
TS_EOFThis is the critical file -- the MCP server that bridges Mercury into Claude Code. It polls the Mercury SQLite DB for new messages and pushes them to Claude Code via notifications/claude/channel.
cat > "$PLUGIN_DIR/server.ts" << 'SERVER_EOF'
#!/usr/bin/env bun
/**
* Mercury channel for Claude Code.
*
* MCP server that bridges the Mercury inter-agent message bus into Claude Code
* as push notifications. Polls Mercury's SQLite DB for new messages on
* subscribed channels and delivers them via notifications/claude/channel.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { Database } from 'bun:sqlite'
import { homedir } from 'os'
import { join } from 'path'
// --- Configuration ---
const IDENTITY = process.env.MERCURY_IDENTITY
if (!IDENTITY) {
process.stderr.write(
`mercury channel: MERCURY_IDENTITY required\n` +
` set env var to your agent identity (e.g. "keeper:murmur", "oracle", "worker:auth")\n`,
)
process.exit(1)
}
const POLL_INTERVAL = parseInt(process.env.MERCURY_POLL_INTERVAL ?? '2000', 10)
const DB_PATH = join(homedir(), '.local', 'share', 'mercury', 'mercury.db')
// --- Database ---
function validateSchema(database: Database): void {
const EXPECTED: Record<string, string[]> = {
messages: ['id', 'channel', 'sender', 'body', 'created_at'],
subscriptions: ['agent', 'channel', 'created_at'],
cursors: ['agent', 'channel', 'last_read_id'],
}
for (const [table, requiredCols] of Object.entries(EXPECTED)) {
const rows = database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
const columns = new Set(rows.map(r => r.name))
const missing = requiredCols.filter(c => !columns.has(c))
if (missing.length > 0) {
throw new Error(
`Mercury schema mismatch: ${table} table is missing columns: ${missing.join(', ')}. ` +
`See https://github.com/gudnuf/mercury/blob/main/docs/SCHEMA.md`
)
}
}
}
let db: Database
try {
db = new Database(DB_PATH)
db.exec('PRAGMA journal_mode = WAL')
db.exec('PRAGMA busy_timeout = 5000')
validateSchema(db)
} catch (err) {
process.stderr.write(`mercury channel: failed to open DB at ${DB_PATH}: ${err}\n`)
process.exit(1)
}
// --- Prepared Statements ---
const stmtInsertMessage = db.prepare(
`INSERT INTO messages (channel, sender, body, created_at) VALUES ($channel, $sender, $body, $created_at)`
)
const stmtInsertSubscription = db.prepare(
`INSERT OR IGNORE INTO subscriptions (agent, channel, created_at) VALUES ($agent, $channel, $created_at)`
)
const stmtDeleteSubscription = db.prepare(
`DELETE FROM subscriptions WHERE agent = $agent AND channel = $channel`
)
const stmtGetSubscriptions = db.prepare(
`SELECT channel FROM subscriptions WHERE agent = $agent`
)
const stmtGetCursor = db.prepare(
`SELECT last_read_id FROM cursors WHERE agent = $agent AND channel = $channel`
)
const stmtUpsertCursor = db.prepare(
`INSERT INTO cursors (agent, channel, last_read_id) VALUES ($agent, $channel, $last_read_id)
ON CONFLICT(agent, channel) DO UPDATE SET last_read_id = excluded.last_read_id`
)
const stmtGetNewMessages = db.prepare(
`SELECT id, channel, sender, body, created_at FROM messages
WHERE channel = $channel AND id > $last_read_id
ORDER BY id ASC`
)
const stmtGetLatestId = db.prepare(
`SELECT COALESCE(MAX(id), 0) as max_id FROM messages WHERE channel = $channel`
)
const stmtChannelStats = db.prepare(
`SELECT channel, COUNT(*) as count, MAX(created_at) as latest FROM messages GROUP BY channel ORDER BY latest DESC`
)
const stmtGetHistory = db.prepare(
`SELECT id, channel, sender, body, created_at FROM messages
WHERE channel = $channel
ORDER BY id DESC LIMIT $limit`
)
const stmtGetAllHistory = db.prepare(
`SELECT id, channel, sender, body, created_at FROM messages
ORDER BY id DESC LIMIT $limit`
)
// --- Types ---
type MessageRow = {
id: number
channel: string
sender: string
body: string
created_at: string
}
type ChannelRow = { channel: string }
type CursorRow = { last_read_id: number }
type LatestRow = { max_id: number }
type StatsRow = { channel: string; count: number; latest: string }
// --- Auto-subscribe based on role ---
function autoSubscribe(): void {
const now = new Date().toISOString()
// All roles subscribe to status
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: 'status', $created_at: now })
const parts = IDENTITY!.split(':')
const role = parts[0]
if (role === 'oracle') {
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: 'studio', $created_at: now })
} else if (role === 'keeper') {
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: 'studio', $created_at: now })
// Subscribe to own channel (e.g. keeper:murmur -> channel "keeper:murmur")
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: IDENTITY!, $created_at: now })
} else if (role === 'worker') {
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: 'workers', $created_at: now })
}
}
autoSubscribe()
// --- Send startup message ---
function sendStartupMessage(): void {
const now = new Date().toISOString()
stmtInsertMessage.run({ $channel: 'status', $sender: IDENTITY, $body: `${IDENTITY} online`, $created_at: now })
}
sendStartupMessage()
// --- MCP Server ---
const mcp = new Server(
{ name: 'mercury', version: '0.0.1' },
{
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
instructions: [
`Messages from Mercury arrive as <channel source="plugin:mercury:mercury" chat_id="CHANNEL" user="SENDER" ...>.`,
`Use the send tool to reply. Pass chat_id as the channel name.`,
`Mercury is the inter-agent message bus — other Claude sessions communicate through it.`,
`Your Mercury identity is ${IDENTITY}. Post status updates to the "status" channel.`,
].join('\n'),
},
)
// --- Tools ---
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'send',
description: 'Send a message to a Mercury channel.',
inputSchema: {
type: 'object' as const,
properties: {
channel: { type: 'string', description: 'Channel name to send to' },
text: { type: 'string', description: 'Message body' },
},
required: ['channel', 'text'],
},
},
{
name: 'read',
description: 'Read unread messages from subscribed channels. Updates cursor after reading.',
inputSchema: {
type: 'object' as const,
properties: {
channel: { type: 'string', description: 'Channel to read (omit for all subscribed channels)' },
},
},
},
{
name: 'subscribe',
description: 'Subscribe to a Mercury channel.',
inputSchema: {
type: 'object' as const,
properties: {
channel: { type: 'string', description: 'Channel name to subscribe to' },
},
required: ['channel'],
},
},
{
name: 'unsubscribe',
description: 'Unsubscribe from a Mercury channel.',
inputSchema: {
type: 'object' as const,
properties: {
channel: { type: 'string', description: 'Channel name to unsubscribe from' },
},
required: ['channel'],
},
},
{
name: 'channels',
description: 'List all Mercury channels with message counts.',
inputSchema: {
type: 'object' as const,
properties: {},
},
},
{
name: 'log',
description: 'Show message history for a channel (or all channels).',
inputSchema: {
type: 'object' as const,
properties: {
channel: { type: 'string', description: 'Channel to show history for (omit for all)' },
limit: { type: 'number', description: 'Max messages to return (default 20)' },
},
},
},
],
}))
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
const args = (req.params.arguments ?? {}) as Record<string, unknown>
try {
switch (req.params.name) {
case 'send': {
const channel = args.channel as string
const text = args.text as string
if (!channel || !text) throw new Error('channel and text are required')
const now = new Date().toISOString()
const result = stmtInsertMessage.run({ $channel: channel, $sender: IDENTITY, $body: text, $created_at: now })
return {
content: [{ type: 'text', text: `sent to #${channel} (id: ${result.lastInsertRowid})` }],
}
}
case 'read': {
const channel = args.channel as string | undefined
const channels = channel
? [channel]
: (stmtGetSubscriptions.all({ $agent: IDENTITY }) as ChannelRow[]).map(r => r.channel)
const lines: string[] = []
for (const ch of channels) {
const cursor = stmtGetCursor.get({ $agent: IDENTITY, $channel: ch }) as CursorRow | undefined
const lastReadId = cursor?.last_read_id ?? 0
// If no cursor exists, initialize to latest (don't replay history)
if (!cursor) {
const latest = stmtGetLatestId.get({ $channel: ch }) as LatestRow
stmtUpsertCursor.run({ $agent: IDENTITY, $channel: ch, $last_read_id: latest.max_id })
lines.push(`#${ch}: (cursor initialized, no unread)`)
continue
}
const msgs = stmtGetNewMessages.all({ $channel: ch, $last_read_id: lastReadId }) as MessageRow[]
if (msgs.length === 0) {
lines.push(`#${ch}: (no unread)`)
continue
}
let maxId = lastReadId
for (const m of msgs) {
lines.push(`#${m.channel} [${m.created_at}] ${m.sender}: ${m.body}`)
if (m.id > maxId) maxId = m.id
}
stmtUpsertCursor.run({ $agent: IDENTITY, $channel: ch, $last_read_id: maxId })
}
return {
content: [{ type: 'text', text: lines.length > 0 ? lines.join('\n') : '(no unread messages)' }],
}
}
case 'subscribe': {
const channel = args.channel as string
if (!channel) throw new Error('channel is required')
const now = new Date().toISOString()
stmtInsertSubscription.run({ $agent: IDENTITY, $channel: channel, $created_at: now })
return { content: [{ type: 'text', text: `subscribed to #${channel}` }] }
}
case 'unsubscribe': {
const channel = args.channel as string
if (!channel) throw new Error('channel is required')
stmtDeleteSubscription.run({ $agent: IDENTITY, $channel: channel })
return { content: [{ type: 'text', text: `unsubscribed from #${channel}` }] }
}
case 'channels': {
const rows = stmtChannelStats.all() as StatsRow[]
if (rows.length === 0) {
return { content: [{ type: 'text', text: '(no channels)' }] }
}
const subs = new Set(
(stmtGetSubscriptions.all({ $agent: IDENTITY }) as ChannelRow[]).map(r => r.channel)
)
const lines = rows.map(r => {
const marker = subs.has(r.channel) ? '*' : ' '
return `${marker} #${r.channel} ${r.count} msgs (latest: ${r.latest})`
})
lines.unshift('(* = subscribed)')
return { content: [{ type: 'text', text: lines.join('\n') }] }
}
case 'log': {
const channel = args.channel as string | undefined
const limit = (args.limit as number) ?? 20
const rows = channel
? stmtGetHistory.all({ $channel: channel, $limit: limit }) as MessageRow[]
: stmtGetAllHistory.all({ $limit: limit }) as MessageRow[]
if (rows.length === 0) {
return { content: [{ type: 'text', text: '(no messages)' }] }
}
// Reverse to show oldest first
const lines = rows.reverse().map(m =>
`[${m.created_at}] #${m.channel} ${m.sender}: ${m.body}`
)
return { content: [{ type: 'text', text: lines.join('\n') }] }
}
default:
return {
content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }],
isError: true,
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return {
content: [{ type: 'text', text: `${req.params.name} failed: ${msg}` }],
isError: true,
}
}
})
// --- Polling for new messages ---
// Track cursors for polling (separate from the tool's cursor — polling uses
// its own in-memory state so tool reads and push notifications don't interfere)
const pollCursors = new Map<string, number>()
function initPollCursors(): void {
const subs = stmtGetSubscriptions.all({ $agent: IDENTITY }) as ChannelRow[]
for (const { channel } of subs) {
// Check if a cursor exists in DB
const cursor = stmtGetCursor.get({ $agent: IDENTITY, $channel: channel }) as CursorRow | undefined
if (cursor) {
pollCursors.set(channel, cursor.last_read_id)
} else {
// No cursor — start from latest message (don't replay history)
const latest = stmtGetLatestId.get({ $channel: channel }) as LatestRow
pollCursors.set(channel, latest.max_id)
// Also persist the cursor
stmtUpsertCursor.run({ $agent: IDENTITY, $channel: channel, $last_read_id: latest.max_id })
}
}
}
initPollCursors()
function pollForMessages(): void {
try {
// Refresh subscription list (in case tools added new subscriptions)
const subs = stmtGetSubscriptions.all({ $agent: IDENTITY }) as ChannelRow[]
for (const { channel } of subs) {
const lastId = pollCursors.get(channel) ?? 0
// If this is a new subscription we haven't seen, init from latest
if (!pollCursors.has(channel)) {
const latest = stmtGetLatestId.get({ $channel: channel }) as LatestRow
pollCursors.set(channel, latest.max_id)
stmtUpsertCursor.run({ $agent: IDENTITY, $channel: channel, $last_read_id: latest.max_id })
continue
}
const msgs = stmtGetNewMessages.all({ $channel: channel, $last_read_id: lastId }) as MessageRow[]
let maxId = lastId
for (const m of msgs) {
// Skip own messages
if (m.sender === IDENTITY) {
if (m.id > maxId) maxId = m.id
continue
}
// Push notification to Claude
mcp.notification({
method: 'notifications/claude/channel',
params: {
content: m.body,
meta: {
chat_id: m.channel,
message_id: String(m.id),
user: m.sender,
ts: m.created_at,
source: 'mercury',
},
},
}).catch(err => {
process.stderr.write(`mercury channel: failed to deliver notification: ${err}\n`)
})
if (m.id > maxId) maxId = m.id
}
if (maxId > lastId) {
pollCursors.set(channel, maxId)
stmtUpsertCursor.run({ $agent: IDENTITY, $channel: channel, $last_read_id: maxId })
}
}
} catch (err) {
process.stderr.write(`mercury channel: poll error: ${err}\n`)
}
}
const pollTimer = setInterval(pollForMessages, POLL_INTERVAL)
// --- Lifecycle ---
process.on('unhandledRejection', err => {
process.stderr.write(`mercury channel: unhandled rejection: ${err}\n`)
})
process.on('uncaughtException', err => {
process.stderr.write(`mercury channel: uncaught exception: ${err}\n`)
})
await mcp.connect(new StdioServerTransport())
process.stderr.write(`mercury channel: online as ${IDENTITY} (polling every ${POLL_INTERVAL}ms)\n`)
let shuttingDown = false
function shutdown(): void {
if (shuttingDown) return
shuttingDown = true
process.stderr.write('mercury channel: shutting down\n')
clearInterval(pollTimer)
try { db.close() } catch {}
setTimeout(() => process.exit(0), 1000)
}
process.stdin.on('end', shutdown)
process.stdin.on('close', shutdown)
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
SERVER_EOFcd "$PLUGIN_DIR"
bun installThese steps happen inside a Claude Code interactive session.
Open Claude Code:
claudeInside the session, run:
/plugin add-marketplace
When prompted, choose "local directory" and enter the marketplace path:
$HOME/forge/marketplace
(Replace $HOME with the actual absolute path, e.g., /home/youruser/forge/marketplace)
Still inside Claude Code:
/plugin install mercury@forge-local
This copies the plugin files to ~/.claude/plugins/mercury/ and registers it in ~/.claude/plugins/installed_plugins.json.
Exit the session after installing.
The MCP server reads MERCURY_IDENTITY to know who the agent is. This must be set before Claude Code launches.
export MERCURY_IDENTITY="keeper:yourname"Mercury push notifications require registering the MCP server as a "development channel." This is done via a Claude Code CLI flag.
Critical rule: pass server:mercury via --dangerously-load-development-channels ONLY. Do NOT also put it in --channels. Putting it in both causes --channels to check the allowlist and block notifications, overriding the dev channel registration.
export MERCURY_IDENTITY="keeper:yourname"
claude \
--dangerously-skip-permissions \
--dangerously-load-development-channels server:mercuryIf you also use the Discord plugin:
export MERCURY_IDENTITY="keeper:yourname"
claude \
--dangerously-skip-permissions \
--dangerously-load-development-channels server:mercury \
--channels plugin:discord@claude-plugins-official--dangerously-load-development-channels triggers an interactive confirmation prompt. In automated setups (tmux), auto-confirm it:
# In tmux, fire Enter after 3 seconds
if [[ -n "${TMUX_PANE:-}" ]]; then
(sleep 3 && tmux send-keys -t "$TMUX_PANE" Enter 2>/dev/null) &
fiFor a complete launch script that handles Mercury + Discord + agent state, see the launch-agent reference in the forge personal setup guide, or use the version at:
https://github.com/gudnuf/mercury (referenced from the forge repo)
Minimal launch-agent:
#!/usr/bin/env bash
set -euo pipefail
IDENTITY="${1:?Usage: launch-agent <identity>}"
FORGE_BASE="${FORGE_BASE:-$HOME/forge}"
export MERCURY_IDENTITY="$IDENTITY"
# Subscribe to standard channels
mercury subscribe --as "$IDENTITY" --channel status
mercury subscribe --as "$IDENTITY" --channel "$IDENTITY"
# Announce
mercury send --as "$IDENTITY" --to status "online"
# Auto-confirm dev channels dialog in tmux
if [[ -n "${TMUX_PANE:-}" ]]; then
(sleep 3 && tmux send-keys -t "$TMUX_PANE" Enter 2>/dev/null) &
fi
exec claude \
--dangerously-skip-permissions \
--dangerously-load-development-channels server:mercury# Send a test message
mercury send --as "setup-test" --to status "hello from setup"
# Read it back
mercury log --channel status
# Expected: shows your message with timestamp
# Check channels exist
mercury channels
# Expected: lists "status" channelLaunch Claude Code with Mercury enabled:
export MERCURY_IDENTITY="test-agent"
claude --dangerously-load-development-channels server:mercuryAccept the development channels prompt. Inside the session:
- Check the MCP server loaded: you should see Mercury tools available (send, read, subscribe, etc.)
- Use the read tool:
mercury read-- should show "(no unread messages)" or recent messages - Use the send tool: send a message to the status channel
- From another terminal:
mercury log --channel status-- your message should appear - From another terminal:
mercury send --as "outside" --to status "ping"-- you should receive a push notification inside the Claude Code session
The key indicator: when you receive a push notification, Claude Code shows it as:
<channel source="plugin:mercury:mercury" chat_id="status" user="outside" ...>
ping
</channel>
If you see Listening for channel messages from: server:mercury in the Claude Code startup output, push notifications are registered.
If you see a warning about server:mercury not being in the allowlist, the --channels flag is interfering -- remove Mercury from --channels and keep it ONLY in --dangerously-load-development-channels.
| Problem | Cause | Fix |
|---|---|---|
mercury: command not found |
Not on PATH | Add the binary's directory to PATH |
failed to open DB |
Mercury DB doesn't exist yet | Run any mercury command first (e.g., mercury channels) to create it |
MERCURY_IDENTITY required |
Env var not set | export MERCURY_IDENTITY="your:identity" before launching |
| Plugin not found in Claude Code | Marketplace not registered | Run /plugin add-marketplace inside Claude Code |
| Push notifications don't arrive | Mercury in --channels too |
Remove from --channels, keep ONLY in --dangerously-load-development-channels |
bun: command not found |
Bun not installed | curl -fsSL https://bun.sh/install | bash |
| Schema mismatch error | Old Mercury DB version | Delete ~/.local/share/mercury/mercury.db and rebuild with latest mercury CLI |
How it fits together:
Mercury CLI (Go) MCP Plugin (Bun/TS) Claude Code
| | |
| mercury send | polls DB every 2s |
| | | | |
v v v v v
+--------------------------------------------------------------------+
| ~/.local/share/mercury/mercury.db |
| messages | subscriptions | cursors |
+--------------------------------------------------------------------+
|
| notifications/claude/channel
+------------------------------> Claude sees
push messages
- The Mercury CLI is the source of truth -- it creates the DB schema on first run.
- The MCP plugin is a consumer -- it reads/writes the same SQLite file via
bun:sqlite. - WAL mode enables concurrent access from CLI, MCP plugin, and any other consumers.
- The DB file at
~/.local/share/mercury/mercury.dbis the entire system state.
Files created by this setup:
$FORGE_BASE/marketplace/
.claude-plugin/marketplace.json # marketplace definition
plugins/mercury/
.claude-plugin/plugin.json # plugin metadata
.mcp.json # MCP server launch config
package.json # npm dependencies
tsconfig.json # TypeScript config
server.ts # the MCP server (main file)
node_modules/ # installed by bun
bun.lock # lockfile
~/.claude/plugins/mercury/ # installed copy (managed by Claude Code)
~/.claude/plugins/installed_plugins.json # plugin registry
~/.claude/plugins/known_marketplaces.json # marketplace registry
~/.local/share/mercury/mercury.db # Mercury database (created by CLI)