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:
@@ -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 <peer> --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="<their-name>", to="${name}", body="...")`,
|
||||
``,
|
||||
];
|
||||
process.stdout.write(lines.join("\n"));
|
||||
});
|
||||
|
||||
program
|
||||
|
||||
100
node/src/mcp.ts
100
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<string, string | string[] | undefined> } })
|
||||
?.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<string, string | string[] | undefined> } })
|
||||
?.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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
44
node/tests/mcp.test.ts
Normal file
44
node/tests/mcp.test.ts
Normal 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`/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user