feat(mcp): identity-via-arg + plugin ships .mcp.json

MCP tools (send/check_inbox/peek_inbox/list_mailboxes) now accept the
caller's mailbox name as an explicit argument (from/name), falling back
to the X-Mailbox header for legacy single-session HTTP setups. This
unblocks multi-session coordination through a shared .mcp.json — each
Claude session passes its own session-derived name on every call,
instead of relying on a single transport header that all sessions
would share.

The plugin now ships .mcp.json (no header), and the SessionStart
announcement spells out the exact args to pass to each mcp__mailbox__*
tool so Claude wires it up automatically.
This commit is contained in:
Mika Kuns
2026-05-19 11:50:58 +02:00
parent 462d6561e1
commit 9fd321043f
7 changed files with 179 additions and 51 deletions

View File

@@ -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-status` — read-only health check
- `/claude-mailbox:mailbox-update` — pull the latest daemon version and restart - `/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 `<base>-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 `<base>-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="<my-name>"),
sends via mcp__mailbox__send(from="<my-name>", to="<peer>", 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. 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. 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 ```json
{ {
"mcpServers": { "mcpServers": {
"mailbox": { "mailbox": {
"type": "http", "type": "http",
"url": "http://127.0.0.1:47822/mcp", "url": "http://127.0.0.1:47822/mcp"
"headers": {
"X-Mailbox": "backend"
}
} }
} }
} }
``` ```
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: Four MCP tools are exposed:
| Tool | Purpose | | Tool | Purpose |
|---|---| |---|---|
| `mcp__mailbox__send(to, body)` | Send a message to another mailbox | | `mcp__mailbox__send(from, to, body)` | Send a message. `from` is your mailbox; falls back to X-Mailbox header. |
| `mcp__mailbox__check_inbox()` | Pull all pending messages for this mailbox (marks delivered) | | `mcp__mailbox__check_inbox(name)` | Pull all pending messages for `name` (marks delivered). Falls back to X-Mailbox header. |
| `mcp__mailbox__peek_inbox()` | Non-consuming check — returns `{ pending, oldestAt }` | | `mcp__mailbox__peek_inbox(name)` | Non-consuming check — returns `{ pending, oldestAt }`. Falls back to X-Mailbox header. |
| `mcp__mailbox__list_mailboxes()` | Discover known mailboxes and who has mail for you | | `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 ### Suggested CLAUDE.md snippet for poll discipline

View File

@@ -201,10 +201,17 @@ program
if (!sid) return; if (!sid) return;
const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null; const base = (process.env["CLAUDE_MAILBOX_NAME"] ?? "").trim() || null;
const name = deriveSessionName(sid, base); const name = deriveSessionName(sid, base);
process.stdout.write( const lines = [
`Claude-Mailbox: this session is mailbox \`${name}\`. ` + `Claude-Mailbox: your mailbox name this session is \`${name}\`.`,
`Peers can send to it with: claude-mailbox send --from <peer> --to ${name} --body "..."\n`, `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="<their-name>", to="${name}", body="...")`,
``,
];
process.stdout.write(lines.join("\n"));
}); });
program program

View File

@@ -5,35 +5,51 @@ import type { FastifyInstance } from "fastify";
import { MailboxStore, rowToMessage } from "./db.js"; import { MailboxStore, rowToMessage } from "./db.js";
import { HEADER_NAME } from "./server.js"; import { HEADER_NAME } from "./server.js";
function buildMcpServer(store: MailboxStore): McpServer { function headerFallback(extra: unknown): string {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
const requireSender = (extra: unknown): string => {
const headers = const headers =
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } }) (extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
?.requestInfo?.headers ?? {}; ?.requestInfo?.headers ?? {};
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"]; const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim(); return (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
if (!value) { }
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
} export function resolveIdentity(
return value; 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" });
server.registerTool( server.registerTool(
"send", "send",
{ {
title: "Send mail", title: "Send mail",
description: 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: { inputSchema: {
to: z.string().describe("Name of the recipient mailbox."), to: z.string().describe("Name of the recipient mailbox."),
body: z.string().describe("Message body (plain text or markdown)."), 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) => { async ({ to, body, from }, extra) => {
const from = requireSender(extra); const sender = resolveIdentity(from, extra, "from");
const r = store.send(from, to, body); const r = store.send(sender, to, body);
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() }; const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
return { return {
content: [{ type: "text", text: JSON.stringify(out) }], content: [{ type: "text", text: JSON.stringify(out) }],
@@ -47,12 +63,19 @@ function buildMcpServer(store: MailboxStore): McpServer {
{ {
title: "Check inbox", title: "Check inbox",
description: description:
"Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.", "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: {}, 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); async ({ name }, extra) => {
const messages = store.checkInbox(name).map((m) => { const me = resolveIdentity(name, extra, "name");
const messages = store.checkInbox(me).map((m) => {
const x = rowToMessage(m); const x = rowToMessage(m);
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() }; 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", title: "Peek inbox",
description: description:
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.", "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: {}, 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); async ({ name }, extra) => {
const status = store.peek(name); const me = resolveIdentity(name, extra, "name");
const status = store.peek(me);
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null }; const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
return { return {
content: [{ type: "text", text: JSON.stringify(out) }], content: [{ type: "text", text: JSON.stringify(out) }],
@@ -86,12 +116,20 @@ function buildMcpServer(store: MailboxStore): McpServer {
"list_mailboxes", "list_mailboxes",
{ {
title: "List mailboxes", title: "List mailboxes",
description: "Discover known mailboxes and how many messages each has waiting for you.", description:
inputSchema: {}, "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); async ({ name }, extra) => {
const list = store.listMailboxes(name).map((m) => ({ const me = resolveIdentity(name, extra, "name");
const list = store.listMailboxes(me).map((m) => ({
name: m.name, name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(), lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou, pendingForYou: m.pendingForYou,

View File

@@ -92,7 +92,8 @@ describe("`session-announce` CLI behavior", () => {
}); });
expect(r.status).toBe(0); expect(r.status).toBe(0);
expect(r.stdout).toContain("`claude-abc12345`"); 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", () => { it("uses base prefix when set", () => {

44
node/tests/mcp.test.ts Normal file
View File

@@ -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`/);
});
});

8
plugin/.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"mailbox": {
"type": "http",
"url": "http://127.0.0.1:47822/mcp"
}
}
}

View File

@@ -37,11 +37,24 @@ The `SessionStart` hook announces the current session's mailbox name in the conv
| Hook | Command | Effect | | 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. | | `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). 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 ## Slash commands
| Command | What it does | | 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-status` | Read-only health check. No changes. |
| `/claude-mailbox:mailbox-update` | Update the daemon to the latest npm version and restart it. | | `/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 `<msg>`."* 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 ```sh
claude-mailbox list # find the recipient's mailbox name claude-mailbox list
claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi" claude-mailbox send --from probe --to backend-a8b3c1d2 --body "hi"
``` ```