Skip to main content
Glama
sunsquare26-ai

universal-peer-mcp

universal-peer-mcp

Claude Code can hand work to another Claude Code session on the same computer. universal-peer-mcp lets a local MCP client — Codex, or another Claude Code — join that conversation. It connects only to sessions that are already running and that you have put on an allowlist yourself.

It is a local stdio MCP server plus a per-user daemon. Nothing listens on a network port.

Quick start: sessions talking like a messenger

After installing, one command sets the machine up, then each session registers itself once.

universal-peer-mcp setup          # shows what it would do: state directory, link on PATH, Codex rule
universal-peer-mcp setup --yes    # does exactly that; ~/.codex/AGENTS.md is backed up first

In each session you want to talk (Claude: the Bash tool or !; Codex: its shell):

universal-peer-mcp register --alias my-claude

Then, from any session or terminal, with no environment variable and no long path:

Command

What it does

universal-peer-mcp post --to <alias> --body-file f [--expect-reply]

Send. The recipient is woken by a doorbell.

universal-peer-mcp inbox [--message-id <id>]

Read. One message by id is returned whole (up to 256 KiB).

universal-peer-mcp post --reply-to <id> --body-file f

Answer. The same answer sent twice is one message.

universal-peer-mcp inbox-ack --message-id <id>

Mark processed, once.

universal-peer-mcp status

Who is running, what is unprocessed, which doorbells did not reach anyone, what needs a hand.

universal-peer-mcp open <alias>

Reopen that session in this terminal with its registered id, permission mode and peer settings.

Nobody waits in silence:

  • If a doorbell cannot reach the recipient, or waits behind a running turn, the sender gets a notice from universal-peer in its own inbox.

  • A message sent with --expect-reply that is processed without a --reply-to answer produces a notice ten minutes later.

  • When a recipient that was away comes back and can actually be reached, a doorbell that could not be sent is rung again — at most twice per message. The body is never sent again. A session opened the wrong way (for example a bare claude --resume) is shown by status as misopened, with the open command that fixes it.

  • A daemon that dies leaves daemon.log and a daemon_previous_unclean or daemon_crashed row. Its lock is cleaned up, and the next command starts a fresh daemon.

Updating an install from a clean checkout is one command. It switches only after checks pass, verifies the live daemon, and can be undone:

bun tools/update-install.mjs --package <installed package dir> --state <state dir>   # --dry-run, --rollback, --recover

Related MCP server: agent-bus

Trust boundary

Read these four before installing. They are properties of the design, not settings.

  1. Same Mac, same user account. Access is gated by a same-uid check and a 0600 control token. Any other program running as the same uid can use this server.

  2. Admin mode is decided when the daemon starts. It comes from the daemon's own startup environment (CLAUDE_PEER_MCP_ADMIN=1). No later request, tool argument, or config file can turn it on.

  3. SIGTERM cleans up. On SIGTERM or SIGINT the daemon removes its socket, identity file, lock, and control token before exiting.

  4. Changing target config requires an explicit verified update and daemon restart. Targets remain pinned to the selected session. Keep the existing state and pending messages; see the upgrade procedure.

What it is not

  • Not an official Anthropic or OpenAI project. See TRADEMARKS.md.

  • Not a permission escalator. The permission mode you launched Claude Code with is the ceiling; this server has no command that changes it.

  • Not a retry queue. A failed send is never retried automatically.

  • Not a tamper-proof ledger. The event file is an append-only local file for surviving restarts, nothing more.

  • Not a session launcher. It never starts, stops, or focuses a Claude Code window.

  • Not a remote or multi-user tool. There is no network transport.

Requirements

Item

Requirement

OS

macOS. The package declares "os": ["darwin"].

Architecture

arm64. The package declares "cpu": ["arm64"], so x64 is refused at install time. It is untested, not merely unsupported — see COMPATIBILITY.md.

Runtime

Bun >= 1.3.0 (engines.bun). The bin runs under #!/usr/bin/env bun.

Peer session

A running Claude Code session that publishes its local peer registry under ~/.claude/sessions/, with peerProtocol 1 and the features notify_idle and reply_across_default_dirs.

Target launch

The target session must have been started with an explicit --permission-mode flag. Without it the permission proof fails closed rather than assuming a default.

MCP client

A client speaking MCP 2026-07-28 or 2025-06-18 over stdio.

Before you install

Four facts have to be true of the session you intend to talk to. Every one of them is enforced fail-closed, so none can be loosened in configuration, and a session that fails one cannot be repaired in place — relaunching it gives it a new session UUID. Check all four before you install anything.

PID=<the target session's pid>

ps -p "$PID" -o args=                                  # 1 and 2, read the line yourself
cat ~/.claude/sessions/"$PID".json                     # 3 and 4
  1. argv[0] is a path, not a bare name. The first token of ps -o args= must be the same string the kernel recorded for the exec. A session started as claude --resume ... through PATH fails; one started as /some/path/claude --resume ... passes. A symbolic link is fine: what is compared is the string handed to execve, and it is not resolved. Do not run readlink -f to "fix" this — the resolved path can differ from what the kernel recorded. Details and the measurement: docs/troubleshooting.md.

  2. An explicit --permission-mode flag is on that line, with a value this adapter maps. See the table under Register one target. auto is enough; bypassPermissions is not required and this package never asks for it.

  3. ~/.claude/sessions/<pid>.json exists and carries peerProtocol: 1, both notify_idle and reply_across_default_dirs in peerFeatures, and a messagingSocketPath.

  4. Its cwd is the absolute path you will write into targets.json, after symlinks.

If a session fails 1 or 2 and you own it, relaunch it. An interactive session needs a terminal: started from a plain background job it has no controlling tty and exits. tmux gives it one.

tmux new-session -d -s peer-target '<path-to-claude> --permission-mode auto'

Measured 2026-09-07: a child of tmux new-session -d reports a tty from ps -o tty=; the same command backgrounded from a script reports ??.

Install

Start here: install a tarball you were handed. That is the shortest path and the only one a test in this repository exercises — test/install.test.mjs installs a tarball into an empty prefix with the network unavailable, runs the installed bin, and uninstalls. You do not need this repository, a clone, or git to install.

shasum -a 256 ./universal-peer-mcp-0.1.0.tgz          # compare against the hash you were given
npm install -g "./universal-peer-mcp-0.1.0.tgz"

To keep the machine's global npm prefix untouched, install into a prefix of your own. This is the exact shape the install test runs.

PREFIX="$HOME/.universal-peer-mcp-local"
mkdir -p "$PREFIX"
npm install -g --prefix "$PREFIX" --no-audit --no-fund --ignore-scripts \
  "./universal-peer-mcp-0.1.0.tgz"
BIN="$PREFIX/bin/universal-peer-mcp"

The package has no dependencies; the installed tree contains no node_modules.

The other ways in, for when you have repository access or once there is a release:

Path

Status

npm install -g universal-peer-mcp

Not available yet. There is no registry release, so this command fails today. The same applies to npx -y universal-peer-mcp.

npm install -g "github:sunsquare26-ai/universal-peer-mcp#<tag>"

Works once the repository is public, with <tag> replaced by a tag from the Releases page. Not verified here, because the repository is not public yet.

Pack it yourself from a clone

Needs repository access. npm pack from a clean working tree, then install the tarball as above.

npm pack
npm install -g "./universal-peer-mcp-$(node -p "require('./package.json').version").tgz"

Check the install. doctor only reads; it prints no token, no process arguments, and no absolute path other than the ~ shorthand for your own home.

universal-peer-mcp doctor

The summary fields of a healthy result:

{
  "ok": true,
  "platform": "darwin",
  "arch": "arm64",
  "runtime": "Bun 1.3.11",
  "stateDirectory": "~/Library/Application Support/claude-peer-mcp",
  "stateDirectorySource": "default",
  "note": "doctor does not print tokens or process arguments",
  "writes": "none — doctor never creates the state directory or any file"
}

The full document also carries system, runtimes, state, targets, claudeRegistry, and codexWake. ok is true only when every one of those is satisfied. Field by field: docs/troubleshooting.md.

Register one target

State lives outside the package, in ~/Library/Application Support/claude-peer-mcp/ — the directory keeps this package's former name, claude-peer-mcp, so that an existing event log is not stranded. The directory must be 0700 and the files 0600; the daemon refuses to start otherwise.

mkdir -p ~/Library/Application\ Support/claude-peer-mcp
chmod 700 ~/Library/Application\ Support/claude-peer-mcp

Copy the shipped example out of the installed package and edit it. npm root -g prints where global packages live:

cp "$(npm root -g)/universal-peer-mcp/targets.example.json" ~/Library/Application\ Support/claude-peer-mcp/targets.json
chmod 600 ~/Library/Application\ Support/claude-peer-mcp/targets.json

Working from a clone instead of an install, copy targets.example.json out of the checkout.

{
  "frontend-review": {
    "sessionId": "10000000-0000-4000-8000-000000000001",
    "cwd": "/path/to/project",
    "expectedDisplayName": "Frontend review",
    "permissionMode": "prompting"
  }
}
  • sessionId — the exact session UUID of the running Claude Code session. This is the address. The name shown on screen is never used as an address.

  • cwd — an absolute path that must resolve, after symlinks, to the session's own working directory.

  • permissionMode — prompting or bypass. It must match the mode proved from the target process's real arguments, otherwise the send fails. This file holds the mapped value, not the flag: a session launched with --permission-mode auto is written here as "prompting".

  • expectedDisplayName — optional, diagnostic only. A mismatch is recorded and shown; it never blocks a send.

The whole mapping, from src/adapters/claude-native-v1/darwin-procargs.mjs:90:

--permission-mode on the target

value in targets.json

default

prompting

plan

prompting

acceptEdits

prompting

auto

prompting

bypassPermissions

bypass

anything else, or the flag absent

not mappable — permission mode argv cannot be proven

auto is enough. Nothing in this package needs bypassPermissions, and nothing here can raise the mode a session was launched with. Claude Code 2.1.260 also accepts manual and dontAsk; this adapter does not map them, and no mapping is added to make an error go away.

The shipped cwd is the placeholder /path/to/project, which does not exist. Until you replace it with a real absolute path, doctor reports targets.schemaValid: false — that is the file being checked, not a broken install.

Full schema, all environment variables, and how to find a session UUID: docs/configuration.md. Korean walkthrough for opening the session and answering it: docs/threads-launch-ko.md.

Restart the daemon after any edit to targets.json. The daemon reads targets once at startup (src/daemon.mjs:21) and the stdio entry point reads them once as well (src/server.mjs:7). Neither watches the file.

kill "$(plutil -extract pid raw -- ~/Library/Application\ Support/claude-peer-mcp/daemon.json)"

The next tool call starts a fresh daemon. Restart your MCP client too, so the tool list picks up the new aliases.

Register the server with your MCP client

This changes your client's own configuration, so it is the client owner's decision, not a step this package requires. Nothing here writes a client config; the commands below are shown so the owner can see exactly what would change, and the equivalent config blocks are shown so the change can be reviewed before it is made. claude mcp add defaults to local scope — do not reach for -s user unless the owner asked for a machine-wide change.

Codex — add the block from examples/codex-config.toml to ~/.codex/config.toml, or:

codex mcp add claude-peer -- universal-peer-mcp serve

Claude Code — use examples/claude-mcp.json, or:

claude mcp add claude-peer -- universal-peer-mcp serve

Optional extensions are off by default and are enabled only on the daemon's command line:

universal-peer-mcp serve --enable milestone --enable code-review

Status, send, wait

There is no CLI for messaging. Everything below is an MCP tool your client calls. The tool list order is fixed.

Tool

What it does

peer_targets

Lists aliases with their configured permission mode and connection state.

peer_status

Re-proves one target's identity and permission mode without sending anything.

peer_send

Sends one message. Requires alias, messageId, threadId, kind, body.

peer_wait

Waits for ack, reply, or idle for one messageId.

peer_list_events

Reads durable local events after a sequence cursor.

daemon_status

Reads redacted daemon health and the event cursor.

daemon_shutdown appears only when the daemon was started in admin mode.

Notes that change how you read the results:

  • messageId is yours, and it is the idempotency key. The same messageId with the same content returns the earlier result instead of sending again. The same messageId with different content is rejected as a conflict.

  • body is 1–65,536 bytes of UTF-8 (src/core/limits.mjs:1). The published JSON Schema says maxLength: 65536, which JSON Schema counts in characters, so non-ASCII text reaches the real limit sooner. kind is 2–64 characters matching ^[a-z][a-z0-9_-]{1,63}$.

  • peer_wait defaults to require: "reply" and a 30,000 ms timeout, with a 300,000 ms ceiling. It never resends.

  • ACK means a defined marker arrived, not that the other side agreed. The result carries evidence distinguishing message_status, idle_notice, and application_ack.

  • After a failed send the result is DELIVERY_UNCERTAIN. Delivery is unknown and nothing is retried for you.

One call, with the arguments your client sends:

{
  "alias": "frontend-review",
  "messageId": "10000000-0000-4000-8000-000000000021",
  "threadId": "10000000-0000-4000-8000-000000000020",
  "kind": "review_request",
  "body": "Run the test suite in /path/to/project and report pass or fail."
}

Then wait for the answer to that exact message:

{ "messageId": "10000000-0000-4000-8000-000000000021", "require": "reply", "timeoutMs": 30000 }

The other side answers with a first line of PEER_ACK v=1 ... or PEER_REPLY v=1 ... verdict=pass. The whole round trip, end to end: docs/demo-ack.md.

Checking what actually happened

Read the ledger file first. It is append-only and fsynced before a connection ends, so it holds every event including the ones the public contract cannot carry.

grep '<the messageId you sent>' \
  "${UNIVERSAL_PEER_MCP_STATE_DIR:-$HOME/Library/Application Support/claude-peer-mcp}/events.jsonl"

peer_list_events is the second look, not the first, and it can fail where the file does not. A response carrying a refusal event can be rejected as a whole — see docs/known-issues.md §4. That failure is not a broken install and not a reason to reinstall or resend: it is the known defect being observed, and the events it could not return are in the file above.

Bytes written is not delivery. To confirm arrival, look for the messageId in the receiving session's own transcript under ~/.claude/projects/<cwd slug>/<sessionId>.jsonl.

Uninstall

kill "$(plutil -extract pid raw -- ~/Library/Application\ Support/claude-peer-mcp/daemon.json)"
codex mcp remove claude-peer
claude mcp remove claude-peer
npm uninstall -g universal-peer-mcp
rm -rf ~/Library/Application\ Support/claude-peer-mcp

The last line deletes your targets and the local event history. Nothing outside that directory is touched.

Documentation

License

Apache-2.0. See LICENSE. Copyright 이형석 (Hyungseok Lee).

Anthropic, Claude, OpenAI, and Codex are trademarks of their respective owners. This project is not an official Anthropic or OpenAI project and is not affiliated with, endorsed by, or sponsored by either company.

0.1.1 repair candidate

daemon_status reports server/daemon build IDs plus startup/current disk-source digests. A running process is not upgraded by replacing its files. Install the same pinned tarball in both prefixes, retain the existing state root, and verify the replacement processes and a real reply.

peer_wait timing out means only that the requested application response was not observed within that call. Continue waiting with the same message ID; do not repeat the send. peer_list_events is paginated by count and UTF-8 size. While hasMore is true, continue with afterSeq: cursor and the same messageId filter. One page is not the whole history.

In-band replies accept a first-line | body separator. A supplied verdict must be pass or fail; missing verdict is allowed, duplicate/malformed verdict refuses correlation. The body is still spooled when no marker correlates. bodyStorageOmitted: write_failed explicitly marks storage failure; a correlated frame from the wrong process is refused before body storage.

Related MCP Connectors

Related MCP Servers