diff --git a/README.md b/README.md
index df77aff..58002af 100644
--- a/README.md
+++ b/README.md
@@ -119,38 +119,50 @@ The doctor command auto-installs the daemon binary via npm (asks first), registe
- `/claude-mailbox:mailbox-status` — read-only health check
- `/claude-mailbox:mailbox-update` — pull the latest daemon version and restart
-**Each Claude session gets its own mailbox identity** derived from the session's UUID — `claude-a8b3c1d2` by default, or `-a8b3c1d2` if you set a prefix. Parallel sessions in the same project automatically get distinct names. The `SessionStart` hook announces the current session's name in context so Claude knows its own identity. Peers discover each other via `mcp__mailbox__list_mailboxes`.
+**Each Claude session gets its own mailbox identity** derived from the session's UUID — `claude-a8b3c1d2` by default, or `-a8b3c1d2` if you set a prefix. Parallel sessions in the same project automatically get distinct names. The `SessionStart` hook announces the current session's name in context so Claude knows its own identity and which args to pass to MCP tools.
+
+The plugin auto-wires the MCP server too. Because two parallel sessions share one `.mcp.json`, the MCP tools take the caller's mailbox name as an **explicit argument** (`from` / `name`) instead of relying on a shared HTTP header — so multi-session coordination just works:
+
+```
+You: "I started a second session, work together on this."
+Claude (session A): looks up peers via mcp__mailbox__list_mailboxes(name=""),
+ sends via mcp__mailbox__send(from="", to="", body="...")
+Claude (session B): receives the message on the next prompt via the UserPromptSubmit hook.
+```
If the daemon goes down later, the hook emits a one-line setup hint instead of staying silent.
See [`plugin/README.md`](./plugin/README.md) for the full walkthrough.
-## Use from a Claude session
+## Use from a Claude session (without the plugin)
-Drop this into your project's `.mcp.json` (one per session, different `X-Mailbox` values):
+If you're not using the Claude Code plugin, drop this into your project's `.mcp.json`:
```json
{
"mcpServers": {
"mailbox": {
"type": "http",
- "url": "http://127.0.0.1:47822/mcp",
- "headers": {
- "X-Mailbox": "backend"
- }
+ "url": "http://127.0.0.1:47822/mcp"
}
}
}
```
+The MCP tools take the caller's identity as an argument. If you want the legacy single-session style where every call uses the same mailbox name without specifying it, add a header (Claude then doesn't need to pass `from` / `name`):
+
+```json
+"headers": { "X-Mailbox": "backend" }
+```
+
Four MCP tools are exposed:
| Tool | Purpose |
|---|---|
-| `mcp__mailbox__send(to, body)` | Send a message to another mailbox |
-| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) |
-| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` |
-| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you |
+| `mcp__mailbox__send(from, to, body)` | Send a message. `from` is your mailbox; falls back to X-Mailbox header. |
+| `mcp__mailbox__check_inbox(name)` | Pull all pending messages for `name` (marks delivered). Falls back to X-Mailbox header. |
+| `mcp__mailbox__peek_inbox(name)` | Non-consuming check — returns `{ pending, oldestAt }`. Falls back to X-Mailbox header. |
+| `mcp__mailbox__list_mailboxes(name)` | Discover known mailboxes; `name` is needed for accurate `pendingForYou`. Falls back to X-Mailbox header. |
### Suggested CLAUDE.md snippet for poll discipline
diff --git a/node/src/cli.ts b/node/src/cli.ts
index 0a6177f..52f9b18 100644
--- a/node/src/cli.ts
+++ b/node/src/cli.ts
@@ -201,10 +201,17 @@ program
if (!sid) return;
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
const name = deriveSessionName(sid, base);
- process.stdout.write(
- `Claude-Mailbox: this session is mailbox \`${name}\`. ` +
- `Peers can send to it with: claude-mailbox send --from --to ${name} --body "..."\n`,
- );
+ const lines = [
+ `Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
+ `When using mcp__mailbox__* tools, ALWAYS pass this name explicitly:`,
+ ` - mcp__mailbox__send: from="${name}"`,
+ ` - mcp__mailbox__check_inbox: name="${name}"`,
+ ` - mcp__mailbox__peek_inbox: name="${name}"`,
+ ` - mcp__mailbox__list_mailboxes: name="${name}"`,
+ `Peers reach you with: mcp__mailbox__send(from="", to="${name}", body="...")`,
+ ``,
+ ];
+ process.stdout.write(lines.join("\n"));
});
program
diff --git a/node/src/mcp.ts b/node/src/mcp.ts
index 9548193..69d4c10 100644
--- a/node/src/mcp.ts
+++ b/node/src/mcp.ts
@@ -5,35 +5,51 @@ import type { FastifyInstance } from "fastify";
import { MailboxStore, rowToMessage } from "./db.js";
import { HEADER_NAME } from "./server.js";
+function headerFallback(extra: unknown): string {
+ const headers =
+ (extra as { requestInfo?: { headers?: Record } })
+ ?.requestInfo?.headers ?? {};
+ const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
+ return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
+}
+
+export function resolveIdentity(
+ argValue: string | undefined,
+ extra: unknown,
+ argName: "from" | "name",
+): string {
+ const explicit = (argValue ?? "").trim();
+ if (explicit) return explicit;
+ const fallback = headerFallback(extra);
+ if (fallback) return fallback;
+ throw new Error(
+ `Pass \`${argName}\` (your mailbox name from the SessionStart announcement) or set the X-Mailbox header in .mcp.json.`,
+ );
+}
+
function buildMcpServer(store: MailboxStore): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
- const requireSender = (extra: unknown): string => {
- const headers =
- (extra as { requestInfo?: { headers?: Record } })
- ?.requestInfo?.headers ?? {};
- const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
- const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
- if (!value) {
- throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
- }
- return value;
- };
-
server.registerTool(
"send",
{
title: "Send mail",
description:
- "Send a message to another mailbox. The sender is the current session's X-Mailbox name.",
+ "Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement); falls back to the X-Mailbox header for single-session HTTP setups.",
inputSchema: {
to: z.string().describe("Name of the recipient mailbox."),
body: z.string().describe("Message body (plain text or markdown)."),
+ from: z
+ .string()
+ .optional()
+ .describe(
+ "Your mailbox name (the sender). Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
+ ),
},
},
- async ({ to, body }, extra) => {
- const from = requireSender(extra);
- const r = store.send(from, to, body);
+ async ({ to, body, from }, extra) => {
+ const sender = resolveIdentity(from, extra, "from");
+ const r = store.send(sender, to, body);
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
@@ -47,12 +63,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
{
title: "Check inbox",
description:
- "Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.",
- inputSchema: {},
+ "Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name (from the SessionStart announcement); falls back to X-Mailbox header.",
+ inputSchema: {
+ name: z
+ .string()
+ .optional()
+ .describe(
+ "Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
+ ),
+ },
},
- async (_args, extra) => {
- const name = requireSender(extra);
- const messages = store.checkInbox(name).map((m) => {
+ async ({ name }, extra) => {
+ const me = resolveIdentity(name, extra, "name");
+ const messages = store.checkInbox(me).map((m) => {
const x = rowToMessage(m);
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
});
@@ -68,12 +91,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
{
title: "Peek inbox",
description:
- "Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
- inputSchema: {},
+ "Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name; falls back to X-Mailbox header.",
+ inputSchema: {
+ name: z
+ .string()
+ .optional()
+ .describe(
+ "Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
+ ),
+ },
},
- async (_args, extra) => {
- const name = requireSender(extra);
- const status = store.peek(name);
+ async ({ name }, extra) => {
+ const me = resolveIdentity(name, extra, "name");
+ const status = store.peek(me);
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
@@ -86,12 +116,20 @@ function buildMcpServer(store: MailboxStore): McpServer {
"list_mailboxes",
{
title: "List mailboxes",
- description: "Discover known mailboxes and how many messages each has waiting for you.",
- inputSchema: {},
+ description:
+ "Discover known mailboxes and how many messages each has waiting for you. Pass `name` with your own mailbox name to get accurate `pendingForYou` counts; falls back to X-Mailbox header.",
+ inputSchema: {
+ name: z
+ .string()
+ .optional()
+ .describe(
+ "Your mailbox name. Take it from the SessionStart announcement. Required unless X-Mailbox is set in .mcp.json.",
+ ),
+ },
},
- async (_args, extra) => {
- const name = requireSender(extra);
- const list = store.listMailboxes(name).map((m) => ({
+ async ({ name }, extra) => {
+ const me = resolveIdentity(name, extra, "name");
+ const list = store.listMailboxes(me).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
diff --git a/node/tests/cli-hook.test.ts b/node/tests/cli-hook.test.ts
index e0f6f2e..fda3eb1 100644
--- a/node/tests/cli-hook.test.ts
+++ b/node/tests/cli-hook.test.ts
@@ -92,7 +92,8 @@ describe("`session-announce` CLI behavior", () => {
});
expect(r.status).toBe(0);
expect(r.stdout).toContain("`claude-abc12345`");
- expect(r.stdout).toContain("claude-mailbox send");
+ expect(r.stdout).toContain("mcp__mailbox__send");
+ expect(r.stdout).toContain(`from="claude-abc12345"`);
});
it("uses base prefix when set", () => {
diff --git a/node/tests/mcp.test.ts b/node/tests/mcp.test.ts
new file mode 100644
index 0000000..7d436e5
--- /dev/null
+++ b/node/tests/mcp.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from "vitest";
+import { resolveIdentity } from "../src/mcp.js";
+
+function fakeExtra(header?: string): unknown {
+ if (header === undefined) return {};
+ return { requestInfo: { headers: { "x-mailbox": header } } };
+}
+
+describe("resolveIdentity", () => {
+ it("prefers the explicit argument when present", () => {
+ expect(resolveIdentity("alice", fakeExtra("bob"), "from")).toBe("alice");
+ });
+
+ it("falls back to X-Mailbox header when arg missing", () => {
+ expect(resolveIdentity(undefined, fakeExtra("bob"), "from")).toBe("bob");
+ });
+
+ it("trims whitespace from explicit arg and header", () => {
+ expect(resolveIdentity(" alice ", fakeExtra(), "from")).toBe("alice");
+ expect(resolveIdentity(undefined, fakeExtra(" bob "), "name")).toBe("bob");
+ });
+
+ it("treats empty arg as missing and falls back", () => {
+ expect(resolveIdentity("", fakeExtra("bob"), "name")).toBe("bob");
+ expect(resolveIdentity(" ", fakeExtra("bob"), "name")).toBe("bob");
+ });
+
+ it("throws with a helpful message when neither is provided", () => {
+ expect(() => resolveIdentity(undefined, fakeExtra(), "from")).toThrow(
+ /Pass `from`.*SessionStart announcement/i,
+ );
+ });
+
+ it("throws referencing the correct arg name", () => {
+ expect(() => resolveIdentity(undefined, fakeExtra(), "name")).toThrow(
+ /Pass `name`/,
+ );
+ });
+
+ it("handles extra without requestInfo", () => {
+ expect(() => resolveIdentity(undefined, {}, "from")).toThrow(/Pass `from`/);
+ expect(() => resolveIdentity(undefined, null, "from")).toThrow(/Pass `from`/);
+ });
+});
diff --git a/plugin/.mcp.json b/plugin/.mcp.json
new file mode 100644
index 0000000..59104dc
--- /dev/null
+++ b/plugin/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "mailbox": {
+ "type": "http",
+ "url": "http://127.0.0.1:47822/mcp"
+ }
+ }
+}
diff --git a/plugin/README.md b/plugin/README.md
index 901c57d..5ed5ed3 100644
--- a/plugin/README.md
+++ b/plugin/README.md
@@ -37,11 +37,24 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
| Hook | Command | Effect |
|---|---|---|
-| `SessionStart` | `claude-mailbox session-announce` | Prints `"Claude-Mailbox: this session is mailbox \`X\`"` so Claude knows its own identity. |
+| `SessionStart` | `claude-mailbox session-announce` | Tells Claude its mailbox name for this session and instructs it on the `from` / `name` args to pass to MCP tools. |
| `UserPromptSubmit` | `claude-mailbox check --hook` | Pulls unread messages for the session's mailbox and injects them as context. Silent on empty inbox; emits a one-line setup hint when the daemon is unreachable. |
Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
+## MCP tools
+
+The plugin also ships a `.mcp.json` so Claude has direct access to the mailbox via tool calls. Because the X-Mailbox header would be the same for two parallel sessions sharing one `.mcp.json`, **each MCP tool takes the caller's mailbox name as an explicit argument** (from the SessionStart announcement):
+
+| Tool | Required args | Purpose |
+|---|---|---|
+| `mcp__mailbox__send` | `from`, `to`, `body` | Send a message to another mailbox. |
+| `mcp__mailbox__check_inbox` | `name` | Pull all undelivered messages for your mailbox (marks delivered). |
+| `mcp__mailbox__peek_inbox` | `name` | Non-consuming count of pending messages. |
+| `mcp__mailbox__list_mailboxes` | `name` | Discover known mailboxes and `pendingForYou` counts. |
+
+The SessionStart announcement spells out the exact args to pass, so Claude picks them up automatically.
+
## Slash commands
| Command | What it does |
@@ -50,12 +63,17 @@ Cost: one local HTTP round-trip per prompt + Node coldstart (~100ms on Windows).
| `/claude-mailbox:mailbox-status` | Read-only health check. No changes. |
| `/claude-mailbox:mailbox-update` | Update the daemon to the latest npm version and restart it. |
-## Sending a message to a peer session
+## Coordinating two Claude Code sessions
-From inside Claude Code, use the MCP tool (the daemon already exposes `mcp__mailbox__*`). From any shell:
+1. Open two Claude Code sessions in the same (or different) project.
+2. Each session's SessionStart hook announces its mailbox name in context.
+3. In session A you can say: *"I have a second session running. Use `mcp__mailbox__list_mailboxes` to find it and send ``."* Claude will discover the peer's mailbox and send via `mcp__mailbox__send`.
+4. Session B's `UserPromptSubmit` hook pulls the message on its next prompt and injects it as context.
+
+You can also send from any shell:
```sh
-claude-mailbox list # find the recipient's mailbox name
+claude-mailbox list
claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi"
```