From 9fd321043fee6c192d0a385e589de143f9204a6b Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 19 May 2026 11:50:58 +0200 Subject: [PATCH] feat(mcp): identity-via-arg + plugin ships .mcp.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 34 ++++++++---- node/src/cli.ts | 15 ++++-- node/src/mcp.ts | 100 +++++++++++++++++++++++++----------- node/tests/cli-hook.test.ts | 3 +- node/tests/mcp.test.ts | 44 ++++++++++++++++ plugin/.mcp.json | 8 +++ plugin/README.md | 26 ++++++++-- 7 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 node/tests/mcp.test.ts create mode 100644 plugin/.mcp.json 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" ```