Agents MCP Server
MCP server for agent-to-agent communication over NATS with live session push via Claude Code Channels. State is in-memory; history lives on a JetStream stream; transport is single-bus (AGENTS_NATS_URL); delivery is uniform for local and cross-host targets. Runs as stdio (one process per user) or as a shared remote server over streamable HTTP.
Design Principles
Built following Block's MCP Playbook:
- Outcomes, not operations — one tool = one agent story
- Flatten arguments — primitives, enums, strong defaults
- Instructions are context — descriptions are prompts for LLMs
- Respect token budget — every response reports
_meta: {chars, lines, ms} - Curate ruthlessly — fewer tools = less LLM decision overhead
Prerequisites
- A NATS server reachable via
AGENTS_NATS_URL, with JetStream enabled - For local stdio use: Node.js >= 18
- For remote HTTP deploys: a container runtime (Docker / Kubernetes)
No DuckDB, no on-disk state, no schema migrations.
Transports
The server picks its transport at boot via AGENTS_TRANSPORT:
| Mode | Value | Use case | |---|---|---| | stdio (default) | stdio | One MCP process per user, spawned by Claude Code over stdin/stdout | | HTTP | http | Single shared process fronted by streamable HTTP; each Claude Code session negotiates its own binding |
Stdio binding is implicit from the moment agent_register is called; HTTP binding is per-session (each connected client carries its own sessionBinding). In both modes, the same tool set and the same NATS subjects are in use — callers see no semantic difference.
Install / Update (local stdio)
One command, idempotent — works the same on first install and for every subsequent update:
curl -fsSL https://raw.githubusercontent.com/Piotr1215/agents-mcp-server/main/scripts/install.sh \
| bash -s -- --nats-url=nats://your-endpoint:4222
On update, --nats-url is optional — the existing endpoint in ~/.claude.json is preserved:
curl -fsSL https://raw.githubusercontent.com/Piotr1215/agents-mcp-server/main/scripts/install.sh | bash
Then /mcp reconnect in any active Claude session (or relaunch claude). That's it.
What the installer does:
- Verifies prereqs:
git,node >= 18,npm,jq. Hard-fails with a clear message if any are missing. - Clones (first run) or fast-forward pulls (subsequent runs) into
~/.local/share/agents-mcp-server(override with--dirorAGENTS_MCP_DIR). - Runs
npm install, which triggers thepreparescript (tsc) — no manualnpm run buildstep, ever. - Writes/updates the
mcpServers.agentsentry in~/.claude.jsonusingjq(idempotent, preserves every other entry). Backs up the file to~/.claude.json.bak-<epoch>before writing.
Prefer to review before running
curl -fsSL https://raw.githubusercontent.com/Piotr1215/agents-mcp-server/main/scripts/install.sh -o install.sh
less install.sh
bash install.sh --nats-url=nats://your-endpoint:4222
Local development
Clone the repo directly and use npm link. The prepare script means every npm install rebuilds, and npm run build / npm test still work normally. Only the symlink-path .claude.json entry needs adjusting to point at your dev checkout.
Remote HTTP deploy
Published as a Docker image for shared deployments (homelab proving ground, loft.rocks rollout, per #124):
piotrzan/agents-mcp-server:<version>
The image bakes in no AGENTS_* defaults — callers (Kubernetes Deployment, docker run -e …) set AGENTS_NATS_URL, AGENTS_TRANSPORT, and AGENTS_HTTP_PORT explicitly. The server fails loud on missing NATS so misconfiguration is caught at boot.
Exposed endpoints:
GET /health→{"status":"ok","version":"<x.y.z>","sessions":<count>}POST /mcp→ Streamable HTTP MCP endpoint (stateful; session id returned inMcp-Session-Idheader)GET /mcp→ server-initiated SSE stream used by Claude Code for live<channel>notification push
Client config for Claude Code:
{
"mcpServers": {
"agents": {
"type": "http",
"url": "http://agents-mcp.<your-host>/mcp"
}
}
}
Configuration reference
| Env | Default | Notes | |---|---|---| | AGENTS_NATS_URL | _(no default)_ | Required; server refuses to start if NATS is unreachable | | AGENTS_TRANSPORT | stdio (code default; no default in the Docker image) | stdio or http | | AGENTS_HTTP_PORT | 3000 (code default) | HTTP mode only | | AGENTS_HISTORY_MAX_AGE_MS | 30d | JetStream stream retention | | AGENTS_HISTORY_MAX_BYTES | 512 MiB | JetStream stream cap | | AGENTS_HISTORY_MAX_MSGS_PER_SUBJECT | 10000 | Per-subject cap | | AGENTS_LOG_FILE | _unset_ | When set, writes a local audit log (stdio installs); unset in the default Docker image |
snd CLI
snd is published by the installer as a bin alongside the server:
snd <agent> <msg...> DM to agent
snd -t <agent> <msg...> DM (explicit)
snd -g <group> <msg...> broadcast to group
snd --human … <msg...> prefix payload with [HUMAN] (wrapper does this for interactive use)
snd --tail subscribe to every DM/broadcast/channel event on the bus (read-only)
Only dependency is AGENTS_NATS_URL. snd talks NATS directly, so it works the same regardless of which MCP transport mode you're on.
Real-time session push
agent_register both joins the conversation and binds the session's identity. From that point on:
- DMs where
to_agent == your namearrive as<channel source="agents" kind="dm" …>tags. - Broadcasts where
group == your grouparrive as<channel source="agents" kind="broadcast" …>tags. - Channel posts arrive as
<channel source="agents" kind="channel" …>tags.
In stdio mode the session is the process; in HTTP mode each connected client holds its own binding and SSE stream. Echo suppression happens at the handler: you never see your own outbound message pushed back at you.
Sessions that haven't called agent_register yet stay send-only; inbound is still captured by the JetStream audit stream and available via channel_history / dm_history / group_history for catch-up reads.
Tools
All tools use name for identification (agents know their names from prompts). Every response includes _meta: { chars, lines, ms } for token awareness.
agent_register
Register as an agent. Returns peers in your group.
{ name: "researcher", description: "Finds information", group?: "default" }
// Returns: { agent_id: "researcher-a1b2c3d4", group: "default", peers: [...] }
agent_id is deterministic — <name>-<sha256(name@host)[:8]> — so it survives process restarts.
agent_deregister
Unregister when done. Idempotent — succeeds even if already gone.
{ name: "researcher" }
agent_broadcast
Send a message to all other agents in a group.
{ name: "researcher", message: "Found the data", priority?: "normal", group?: "all" }
agent_dm
Direct message to a specific agent.
{ name: "researcher", to: "analyst", message: "Check this" }
agent_discover
List active agents (local + remote presence cache).
{ include_stale?: false, group?: "research" }
agent_groups
List groups with agent counts.
{}
channel_send
Post to a channel (async bulletin board — no live nudge; use agent_broadcast / agent_dm for push).
{ name: "researcher", channel: "general", message: "Update complete" }
channel_history
Get channel messages. detailed: true returns full metadata.
{ channel: "general", limit?: 50, detailed?: false }
dm_history
Get DM history between two agents.
{ name: "researcher", with_agent: "analyst", limit?: 50, detailed?: false }
channel_list
List channels with message counts.
{}
group_history
Get recent broadcasts for a group.
{ group: "research", limit?: 50 }
messages_since
Poll for new messages since a given JetStream sequence.
{ since_id?: 0, limit?: 100 }
poll_messages
Poll DMs + broadcasts addressed to a given agent since last check.
{ name: "researcher", since_id?: 0 }
How It Works
One bus, one audit store. Presence, DMs, broadcasts, and channel posts all flow through NATS on AGENTS_NATS_URL. A single JetStream stream (agents-history) captures every DM/channel/broadcast subject for history reads.
State
- Agent registry — in-memory
Mapof local agents; remote peers served from the NATS presence cache (10s beat, 30s TTL). No on-disk state, no DuckDB. - Message history — JetStream stream
agents-historywith subject filteragents.dm.>,agents.channel.>,agents.broadcast.>. Retention: 30d / 512 MiB / 10 000 msgs per subject (env-tunable). Every*_historytool opens an ephemeral JetStream consumer with a subject filter, drains up tolimit, deletes the consumer.
NATS subjects
agents.presence— presence beats (not retained in the stream)agents.dm.<base64url(to_agent)>— direct messagesagents.channel.<base64url(channel_name)>— channel postsagents.broadcast.<base64url(group)>— group broadcasts
End-to-end trace (channel post)
bob channel_send("#eng", "hi")
│
└──► NATS publish agents.channel.<b64url(#eng)>
│
┌──────────────┴──────────────┐
▼ ▼
JetStream stream agents-mcp-server sessions
agents-history bound to other agents
│ │
▼ ▼
channel_history reads notifications/claude/channel →
return this seq later <channel source="agents" kind="channel" …>
rendered live in the bound session
Sub-second session push
When Claude Code is launched with --dangerously-load-development-channels server:agents, the same subprocess handles both tool calls and the experimental claude/channel capability — no separate channel binary. Each NATS subscription fan-ins into every bound session whose binding matches the target. Publishers never see their own messages pushed back.
Token Efficiency
Every response includes _meta:
Active agents (2) in group 'default':
- alice (alice-7c3f9a81): active | group: default | host: serval | local
- bob (bob-f600ddba): active | group: default | host: agents-mcp-pod | remote
---
_meta: {"chars":170,"lines":3,"ms":8}
Development
npm install
npm run build
npm test
Docker image:
docker build -t agents-mcp-server:dev .
docker run --rm -e AGENTS_NATS_URL=nats://host.docker.internal:4222 -p 3000:3000 agents-mcp-server:dev
curl http://localhost:3000/health
License
MIT






