feat(node): add TypeScript sibling project for npm-based install
Introduces @kuns/claude-mailbox under node/, a wire-compatible TypeScript port of the .NET daemon that distributes via the public Gitea npm registry. The .NET project stays in src/ClaudeMailbox/ untouched; users pick whichever flavor they prefer. - node/ project: fastify + @modelcontextprotocol/sdk StreamableHTTPServerTransport + better-sqlite3, schema and wire surface match the C# version (port 47822, X-Mailbox header, MCP tool names, snake_case SQLite columns) - Cross-platform autostart: Scheduled Task (Win, no admin) / Windows Service (Win, --service) / launchd (mac) / systemd --user (linux) - 9/9 vitest tests pass; end-to-end /health + send/check round-trip verified - CI split: existing ci.yml/release.yml renamed to *-dotnet.yml with path filters, new ci-node.yml and release-node.yml publish to Gitea npm registry - install.ps1 / install.sh bootstrap one-liners at repo root; homebrew/ contains a tap formula template - README install section reordered: npm path primary, dotnet publish secondary Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
node/.gitignore
vendored
Normal file
5
node/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
20
node/README.md
Normal file
20
node/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @kuns/claude-mailbox
|
||||
|
||||
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. TypeScript / Node port of the .NET `claude-mailbox` daemon — wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
|
||||
|
||||
## Install
|
||||
|
||||
One-time per machine:
|
||||
|
||||
```sh
|
||||
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
|
||||
npm install -g @kuns/claude-mailbox
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default
|
||||
```
|
||||
|
||||
See the repository [README](../README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.
|
||||
3756
node/package-lock.json
generated
Normal file
3756
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
node/package.json
Normal file
55
node/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@kuns/claude-mailbox",
|
||||
"version": "0.0.0",
|
||||
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"claude-mailbox": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"start": "node dist/cli.js serve",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-windows": "^1.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/node": "^22.7.4",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^2.1.1"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"claude",
|
||||
"mailbox",
|
||||
"ipc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.kuns.dev/releases/ClaudeMailbox.git"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.kuns.dev/api/packages/releases/npm/"
|
||||
}
|
||||
}
|
||||
121
node/src/autostart/darwin.ts
Normal file
121
node/src/autostart/darwin.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||
import { userConfigPath } from "../config.js";
|
||||
|
||||
const LABEL = "dev.kuns.claude-mailbox";
|
||||
|
||||
function plistPath(): string {
|
||||
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
||||
}
|
||||
|
||||
function logDir(): string {
|
||||
return join(homedir(), "Library", "Logs", "ClaudeMailbox");
|
||||
}
|
||||
|
||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||
const path = userConfigPath();
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const seed: Record<string, unknown> = {};
|
||||
if (opts.port !== undefined) seed.port = opts.port;
|
||||
if (opts.bind !== undefined) seed.bind = opts.bind;
|
||||
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
|
||||
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildPlist(node: string, script: string, configPath: string): string {
|
||||
mkdirSync(logDir(), { recursive: true });
|
||||
const argv = [node, script, "serve", "--config", configPath];
|
||||
const argsXml = argv
|
||||
.map((a) => ` <string>${escapeXml(a)}</string>`)
|
||||
.join("\n");
|
||||
const stdout = join(logDir(), "stdout.log");
|
||||
const stderr = join(logDir(), "stderr.log");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${argsXml}
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${escapeXml(stdout)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${escapeXml(stderr)}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
export function darwinManager(): AutostartManager {
|
||||
return {
|
||||
mode: "default",
|
||||
async install(opts) {
|
||||
const configPath = ensureConfigSeeded(opts);
|
||||
const { node, script } = cliEntry();
|
||||
const plist = buildPlist(node, script, configPath);
|
||||
const path = plistPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, plist, "utf8");
|
||||
run("launchctl", ["unload", path]);
|
||||
const r = run("launchctl", ["load", "-w", path]);
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`launchctl load failed: ${r.stderr || r.stdout}`);
|
||||
}
|
||||
},
|
||||
async uninstall(purge) {
|
||||
const path = plistPath();
|
||||
if (existsSync(path)) {
|
||||
run("launchctl", ["unload", path]);
|
||||
unlinkSync(path);
|
||||
}
|
||||
if (purge) {
|
||||
const cfg = userConfigPath();
|
||||
if (existsSync(cfg)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
|
||||
if (parsed.dbPath && existsSync(parsed.dbPath)) {
|
||||
rmSync(parsed.dbPath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
unlinkSync(cfg);
|
||||
}
|
||||
}
|
||||
},
|
||||
async start() {
|
||||
const r = run("launchctl", ["start", LABEL]);
|
||||
if (r.status !== 0) throw new Error(`launchctl start failed: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
async stop() {
|
||||
run("launchctl", ["stop", LABEL]);
|
||||
},
|
||||
async status() {
|
||||
if (!existsSync(plistPath())) return "NotInstalled";
|
||||
const r = run("launchctl", ["list", LABEL]);
|
||||
if (r.status !== 0) return "Stopped";
|
||||
const pidMatch = r.stdout.match(/"PID"\s*=\s*(\d+)/);
|
||||
return pidMatch ? "Running" : "Stopped";
|
||||
},
|
||||
};
|
||||
}
|
||||
59
node/src/autostart/index.ts
Normal file
59
node/src/autostart/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export interface AutostartInstallOpts {
|
||||
port?: number;
|
||||
bind?: string;
|
||||
dbPath?: string;
|
||||
}
|
||||
|
||||
export interface AutostartManager {
|
||||
readonly mode: "default" | "service";
|
||||
install(opts: AutostartInstallOpts): Promise<void>;
|
||||
uninstall(purge: boolean): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
status(): Promise<"Running" | "Stopped" | "NotInstalled">;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export function run(file: string, args: string[]): RunResult {
|
||||
const opts: SpawnSyncOptionsWithStringEncoding = {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
};
|
||||
const r = spawnSync(file, args, opts);
|
||||
return {
|
||||
status: r.status ?? -1,
|
||||
stdout: typeof r.stdout === "string" ? r.stdout : "",
|
||||
stderr: typeof r.stderr === "string" ? r.stderr : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function cliEntry(): { node: string; script: string } {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return {
|
||||
node: process.execPath,
|
||||
script: resolve(here, "..", "cli.js"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function autostartManager(mode: "default" | "service" = "default"): Promise<AutostartManager> {
|
||||
if (process.platform === "win32") {
|
||||
const mod = await import("./windows.js");
|
||||
return mod.windowsManager(mode);
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
const mod = await import("./darwin.js");
|
||||
return mod.darwinManager();
|
||||
}
|
||||
const mod = await import("./linux.js");
|
||||
return mod.linuxManager();
|
||||
}
|
||||
104
node/src/autostart/linux.ts
Normal file
104
node/src/autostart/linux.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||
import { userConfigPath } from "../config.js";
|
||||
|
||||
const UNIT_NAME = "claude-mailbox.service";
|
||||
|
||||
function unitPath(): string {
|
||||
const xdg = process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config");
|
||||
return join(xdg, "systemd", "user", UNIT_NAME);
|
||||
}
|
||||
|
||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||
const path = userConfigPath();
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const seed: Record<string, unknown> = {};
|
||||
if (opts.port !== undefined) seed.port = opts.port;
|
||||
if (opts.bind !== undefined) seed.bind = opts.bind;
|
||||
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
|
||||
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function buildUnit(node: string, script: string, configPath: string): string {
|
||||
const exec = `${shellQuote(node)} ${shellQuote(script)} serve --config ${shellQuote(configPath)}`;
|
||||
return `[Unit]
|
||||
Description=ClaudeMailbox MCP mail daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${exec}
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
function systemctl(args: string[]): { status: number; stdout: string; stderr: string } {
|
||||
return run("systemctl", ["--user", ...args]);
|
||||
}
|
||||
|
||||
export function linuxManager(): AutostartManager {
|
||||
return {
|
||||
mode: "default",
|
||||
async install(opts) {
|
||||
const configPath = ensureConfigSeeded(opts);
|
||||
const { node, script } = cliEntry();
|
||||
const path = unitPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, buildUnit(node, script, configPath), "utf8");
|
||||
const reload = systemctl(["daemon-reload"]);
|
||||
if (reload.status !== 0) {
|
||||
throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`);
|
||||
}
|
||||
const enable = systemctl(["enable", "--now", UNIT_NAME]);
|
||||
if (enable.status !== 0) {
|
||||
throw new Error(`systemctl enable --now failed: ${enable.stderr || enable.stdout}`);
|
||||
}
|
||||
},
|
||||
async uninstall(purge) {
|
||||
systemctl(["disable", "--now", UNIT_NAME]);
|
||||
const path = unitPath();
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
systemctl(["daemon-reload"]);
|
||||
if (purge) {
|
||||
const cfg = userConfigPath();
|
||||
if (existsSync(cfg)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
|
||||
if (parsed.dbPath && existsSync(parsed.dbPath)) {
|
||||
rmSync(parsed.dbPath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
unlinkSync(cfg);
|
||||
}
|
||||
}
|
||||
},
|
||||
async start() {
|
||||
const r = systemctl(["start", UNIT_NAME]);
|
||||
if (r.status !== 0) throw new Error(`systemctl start failed: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
async stop() {
|
||||
systemctl(["stop", UNIT_NAME]);
|
||||
},
|
||||
async status() {
|
||||
if (!existsSync(unitPath())) return "NotInstalled";
|
||||
const r = systemctl(["is-active", UNIT_NAME]);
|
||||
if (r.stdout.trim() === "active") return "Running";
|
||||
return "Stopped";
|
||||
},
|
||||
};
|
||||
}
|
||||
226
node/src/autostart/windows.ts
Normal file
226
node/src/autostart/windows.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
|
||||
import { userConfigPath } from "../config.js";
|
||||
|
||||
const TASK_NAME = "ClaudeMailbox";
|
||||
const SERVICE_NAME = "ClaudeMailbox";
|
||||
|
||||
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
|
||||
const path = userConfigPath();
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
const seed: Record<string, unknown> = {};
|
||||
if (opts.port !== undefined) seed.port = opts.port;
|
||||
if (opts.bind !== undefined) seed.bind = opts.bind;
|
||||
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
|
||||
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function buildServeCommand(): { node: string; script: string; configPath: string } {
|
||||
const { node, script } = cliEntry();
|
||||
return { node, script, configPath: userConfigPath() };
|
||||
}
|
||||
|
||||
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
|
||||
const configPath = ensureConfigSeeded(opts);
|
||||
const { node, script } = buildServeCommand();
|
||||
const tr = `"${node}" "${script}" serve --config "${configPath}"`;
|
||||
const r = run("schtasks.exe", [
|
||||
"/Create",
|
||||
"/SC",
|
||||
"ONLOGON",
|
||||
"/TN",
|
||||
TASK_NAME,
|
||||
"/TR",
|
||||
tr,
|
||||
"/RL",
|
||||
"LIMITED",
|
||||
"/F",
|
||||
]);
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||
}
|
||||
const start = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||
if (start.status !== 0) {
|
||||
console.warn(`Task created but /Run returned ${start.status}: ${start.stderr.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduledTaskUninstall(purge: boolean): void {
|
||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
||||
if (r.status !== 0 && !/cannot find/i.test(r.stderr)) {
|
||||
throw new Error(`schtasks /Delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
|
||||
}
|
||||
if (purge) purgeData();
|
||||
}
|
||||
|
||||
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
|
||||
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
|
||||
if (r.status !== 0) {
|
||||
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) return "NotInstalled";
|
||||
return "Stopped";
|
||||
}
|
||||
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
|
||||
return "Stopped";
|
||||
}
|
||||
|
||||
function scheduledTaskRun(): void {
|
||||
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
|
||||
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
|
||||
}
|
||||
|
||||
function scheduledTaskEnd(): void {
|
||||
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
|
||||
}
|
||||
|
||||
interface NodeWindowsService {
|
||||
on(event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop", cb: () => void): void;
|
||||
install(): void;
|
||||
uninstall(): void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
exists?: boolean;
|
||||
}
|
||||
|
||||
interface NodeWindowsModule {
|
||||
Service: new (opts: {
|
||||
name: string;
|
||||
description?: string;
|
||||
script: string;
|
||||
nodeOptions?: string[];
|
||||
workingDirectory?: string;
|
||||
}) => NodeWindowsService;
|
||||
}
|
||||
|
||||
async function loadNodeWindows(): Promise<NodeWindowsModule> {
|
||||
try {
|
||||
return (await import("node-windows")) as unknown as NodeWindowsModule;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
"node-windows is not installed. Install it with `npm i -g node-windows` or use the default Scheduled Task autostart instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isAdministrator(): boolean {
|
||||
const r = run("net.exe", ["session"]);
|
||||
return r.status === 0;
|
||||
}
|
||||
|
||||
async function serviceInstall(opts: AutostartInstallOpts): Promise<void> {
|
||||
if (!isAdministrator()) {
|
||||
throw new Error("install-autostart --service requires an Administrator shell.");
|
||||
}
|
||||
ensureConfigSeeded(opts);
|
||||
const { script, configPath } = buildServeCommand();
|
||||
const nw = await loadNodeWindows();
|
||||
await new Promise<void>((resolveFn, rejectFn) => {
|
||||
const svc = new nw.Service({
|
||||
name: SERVICE_NAME,
|
||||
description: "ClaudeMailbox MCP mail daemon for parallel Claude session coordination.",
|
||||
script,
|
||||
nodeOptions: [],
|
||||
});
|
||||
svc.on("install", () => {
|
||||
svc.start();
|
||||
resolveFn();
|
||||
});
|
||||
svc.on("alreadyinstalled", () => resolveFn());
|
||||
try {
|
||||
svc.install();
|
||||
} catch (e) {
|
||||
rejectFn(e);
|
||||
}
|
||||
void configPath;
|
||||
});
|
||||
}
|
||||
|
||||
async function serviceUninstall(purge: boolean): Promise<void> {
|
||||
if (!isAdministrator()) {
|
||||
throw new Error("uninstall-autostart --service requires an Administrator shell.");
|
||||
}
|
||||
const { script } = buildServeCommand();
|
||||
const nw = await loadNodeWindows();
|
||||
await new Promise<void>((resolveFn, rejectFn) => {
|
||||
const svc = new nw.Service({ name: SERVICE_NAME, script });
|
||||
svc.on("uninstall", () => resolveFn());
|
||||
try {
|
||||
svc.uninstall();
|
||||
} catch (e) {
|
||||
rejectFn(e);
|
||||
}
|
||||
});
|
||||
if (purge) purgeData();
|
||||
}
|
||||
|
||||
function serviceStatus(): "Running" | "Stopped" | "NotInstalled" {
|
||||
const r = run("sc.exe", ["query", SERVICE_NAME]);
|
||||
if (r.status !== 0) return "NotInstalled";
|
||||
if (/STATE\s*:\s*\d+\s+RUNNING/i.test(r.stdout)) return "Running";
|
||||
return "Stopped";
|
||||
}
|
||||
|
||||
function serviceStart(): void {
|
||||
const r = run("sc.exe", ["start", SERVICE_NAME]);
|
||||
if (r.status !== 0) throw new Error(`sc start failed: ${r.stderr || r.stdout}`);
|
||||
}
|
||||
|
||||
function serviceStop(): void {
|
||||
const r = run("sc.exe", ["stop", SERVICE_NAME]);
|
||||
if (r.status !== 0 && !/has not been started/i.test(r.stdout)) {
|
||||
throw new Error(`sc stop failed: ${r.stderr || r.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeData(): void {
|
||||
const cfg = userConfigPath();
|
||||
if (existsSync(cfg)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
|
||||
void parsed;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function windowsManager(mode: "default" | "service"): AutostartManager {
|
||||
if (mode === "service") {
|
||||
return {
|
||||
mode,
|
||||
install: serviceInstall,
|
||||
uninstall: serviceUninstall,
|
||||
async start() {
|
||||
serviceStart();
|
||||
},
|
||||
async stop() {
|
||||
serviceStop();
|
||||
},
|
||||
async status() {
|
||||
return serviceStatus();
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
async install(opts) {
|
||||
scheduledTaskInstall(opts);
|
||||
},
|
||||
async uninstall(purge) {
|
||||
scheduledTaskUninstall(purge);
|
||||
},
|
||||
async start() {
|
||||
scheduledTaskRun();
|
||||
},
|
||||
async stop() {
|
||||
scheduledTaskEnd();
|
||||
},
|
||||
async status() {
|
||||
return scheduledTaskStatus();
|
||||
},
|
||||
};
|
||||
}
|
||||
204
node/src/cli.ts
Normal file
204
node/src/cli.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
|
||||
import { startServer } from "./server.js";
|
||||
import { autostartManager } from "./autostart/index.js";
|
||||
|
||||
function readVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
|
||||
version?: string;
|
||||
};
|
||||
return pkg.version ?? "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||
|
||||
async function callJson(
|
||||
method: string,
|
||||
url: string,
|
||||
init: { headers?: Record<string, string>; body?: unknown } = {},
|
||||
): Promise<unknown> {
|
||||
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
|
||||
let body: string | undefined;
|
||||
if (init.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(init.body);
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${method} ${url} → ${res.status}: ${text}`);
|
||||
}
|
||||
return text.length ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function reportClientError(err: unknown, url: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Could not reach daemon at ${url}: ${msg}`);
|
||||
console.error("Is 'claude-mailbox serve' running?");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.name("claude-mailbox")
|
||||
.description("MCP mail server that lets parallel Claude sessions coordinate.")
|
||||
.version(readVersion(), "-V, --version");
|
||||
|
||||
program
|
||||
.command("serve")
|
||||
.description("Run the daemon in the foreground.")
|
||||
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
||||
.option("--bind <address>", "Bind address")
|
||||
.option("--db-path <path>", "SQLite database path")
|
||||
.option("--config <path>", "Path to mailbox.json")
|
||||
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
|
||||
const cfg = resolveConfig(opts);
|
||||
try {
|
||||
const { app } = await startServer(cfg);
|
||||
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/EADDRINUSE|already in use/i.test(msg)) {
|
||||
console.error(
|
||||
`Port ${cfg.port} is already in use. Another claude-mailbox instance may be running.`,
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("send")
|
||||
.description("Send a message via REST.")
|
||||
.requiredOption("--to <name>", "Recipient mailbox")
|
||||
.requiredOption("--from <name>", "Sender mailbox (X-Mailbox header)")
|
||||
.requiredOption("--body <text>", "Message body")
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.action(async (opts: { to: string; from: string; body: string; url: string }) => {
|
||||
try {
|
||||
const out = await callJson("POST", `${opts.url}/v1/send`, {
|
||||
headers: { "X-Mailbox": opts.from },
|
||||
body: { to: opts.to, body: opts.body },
|
||||
});
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} catch (err) {
|
||||
reportClientError(err, opts.url);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("peek")
|
||||
.description("Non-consuming inbox status.")
|
||||
.requiredOption("--name <name>", "Mailbox name")
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.action(async (opts: { name: string; url: string }) => {
|
||||
try {
|
||||
const out = await callJson(
|
||||
"GET",
|
||||
`${opts.url}/v1/peek?name=${encodeURIComponent(opts.name)}`,
|
||||
);
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} catch (err) {
|
||||
reportClientError(err, opts.url);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("check")
|
||||
.description("Pull pending messages and mark delivered.")
|
||||
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.action(async (opts: { name: string; url: string }) => {
|
||||
try {
|
||||
const out = await callJson(
|
||||
"POST",
|
||||
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`,
|
||||
{ headers: { "X-Mailbox": opts.name } },
|
||||
);
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} catch (err) {
|
||||
reportClientError(err, opts.url);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("list")
|
||||
.description("List known mailboxes.")
|
||||
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
|
||||
.action(async (opts: { url: string }) => {
|
||||
try {
|
||||
const out = await callJson("GET", `${opts.url}/v1/list`);
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} catch (err) {
|
||||
reportClientError(err, opts.url);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("install-autostart")
|
||||
.description(
|
||||
"Register autostart for the current OS (Scheduled Task / launchd / systemd-user). Use --service on Windows for a Windows Service (admin).",
|
||||
)
|
||||
.option("--service", "Windows: install as a Windows Service (requires admin) instead of a Scheduled Task")
|
||||
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
|
||||
.option("--bind <address>", "Bind address")
|
||||
.option("--db-path <path>", "SQLite database path")
|
||||
.action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => {
|
||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
|
||||
console.log("Autostart installed.");
|
||||
});
|
||||
|
||||
program
|
||||
.command("uninstall-autostart")
|
||||
.description("Remove autostart for the current OS.")
|
||||
.option("--service", "Windows: uninstall the Windows Service variant")
|
||||
.option("--purge", "Also delete database and config")
|
||||
.action(async (opts: { service?: boolean; purge?: boolean }) => {
|
||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||
await mgr.uninstall(!!opts.purge);
|
||||
console.log("Autostart removed.");
|
||||
});
|
||||
|
||||
program
|
||||
.command("start")
|
||||
.description("Start the autostart-managed daemon.")
|
||||
.option("--service", "Windows: target the Windows Service variant")
|
||||
.action(async (opts: { service?: boolean }) => {
|
||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||
await mgr.start();
|
||||
});
|
||||
|
||||
program
|
||||
.command("stop")
|
||||
.description("Stop the autostart-managed daemon.")
|
||||
.option("--service", "Windows: target the Windows Service variant")
|
||||
.action(async (opts: { service?: boolean }) => {
|
||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||
await mgr.stop();
|
||||
});
|
||||
|
||||
program
|
||||
.command("status")
|
||||
.description("Print autostart status (Running | Stopped | NotInstalled).")
|
||||
.option("--service", "Windows: target the Windows Service variant")
|
||||
.action(async (opts: { service?: boolean }) => {
|
||||
const mgr = await autostartManager(opts.service ? "service" : "default");
|
||||
console.log(await mgr.status());
|
||||
});
|
||||
|
||||
program.parseAsync(process.argv).catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
91
node/src/config.ts
Normal file
91
node/src/config.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export const DEFAULT_PORT = 47822;
|
||||
export const DEFAULT_BIND = "127.0.0.1";
|
||||
|
||||
export interface FileConfig {
|
||||
port?: number;
|
||||
bind?: string;
|
||||
dbPath?: string;
|
||||
}
|
||||
|
||||
export interface DaemonConfig {
|
||||
port: number;
|
||||
bind: string;
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
export function defaultDbPath(): string {
|
||||
return join(homedir(), ".claude-mailbox", "mailbox.db");
|
||||
}
|
||||
|
||||
export function userConfigPath(): string {
|
||||
return join(homedir(), ".claude-mailbox", "mailbox.json");
|
||||
}
|
||||
|
||||
export function machineConfigPath(): string | null {
|
||||
if (process.platform === "win32") {
|
||||
const programData = process.env["ProgramData"] ?? "C:\\ProgramData";
|
||||
return join(programData, "ClaudeMailbox", "mailbox.json");
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
return "/Library/Application Support/ClaudeMailbox/mailbox.json";
|
||||
}
|
||||
return "/etc/claude-mailbox/mailbox.json";
|
||||
}
|
||||
|
||||
function expandPath(p: string): string {
|
||||
let out = p;
|
||||
if (out.startsWith("~")) out = join(homedir(), out.slice(1));
|
||||
out = out.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
|
||||
out = out.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => process.env[name] ?? "");
|
||||
return resolve(out);
|
||||
}
|
||||
|
||||
export function loadFileConfig(explicitPath?: string): FileConfig {
|
||||
const candidates: string[] = [];
|
||||
if (explicitPath) {
|
||||
if (!existsSync(explicitPath)) {
|
||||
throw new Error(`Config file not found: ${explicitPath}`);
|
||||
}
|
||||
candidates.push(explicitPath);
|
||||
} else {
|
||||
candidates.push(userConfigPath());
|
||||
const machine = machineConfigPath();
|
||||
if (machine) candidates.push(machine);
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = JSON.parse(raw) as FileConfig;
|
||||
return {
|
||||
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
||||
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
|
||||
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export interface ServeOverrides {
|
||||
port?: number;
|
||||
bind?: string;
|
||||
dbPath?: string;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
|
||||
const file = loadFileConfig(overrides.config);
|
||||
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
|
||||
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
|
||||
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
|
||||
return { port, bind, dbPath: expandPath(dbPathRaw) };
|
||||
}
|
||||
|
||||
export function baseUrl(cfg: { port: number; bind: string }): string {
|
||||
return `http://${cfg.bind}:${cfg.port}`;
|
||||
}
|
||||
182
node/src/db.ts
Normal file
182
node/src/db.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
export interface MailboxRow {
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface MessageRow {
|
||||
id: number;
|
||||
to_mailbox: string;
|
||||
from_mailbox: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
delivered_at: string | null;
|
||||
}
|
||||
|
||||
export interface InboxStatus {
|
||||
pending: number;
|
||||
oldestAt: Date | null;
|
||||
}
|
||||
|
||||
export interface MailboxInfo {
|
||||
name: string;
|
||||
lastSeenAt: Date;
|
||||
pendingForYou: number;
|
||||
}
|
||||
|
||||
const DDL_STATEMENTS: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS mailboxes (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
to_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
|
||||
from_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS ix_messages_to_delivered
|
||||
ON messages (to_mailbox, delivered_at)`,
|
||||
];
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseDate(s: string | null | undefined): Date | null {
|
||||
if (!s) return null;
|
||||
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
|
||||
const d = new Date(normalized);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export class MailboxStore {
|
||||
private readonly db: Database.Database;
|
||||
|
||||
private readonly stmts: {
|
||||
findMailbox: Database.Statement;
|
||||
insertMailbox: Database.Statement;
|
||||
touchMailbox: Database.Statement;
|
||||
listMailboxes: Database.Statement;
|
||||
insertMessage: Database.Statement;
|
||||
countPending: Database.Statement;
|
||||
oldestPending: Database.Statement;
|
||||
selectPending: Database.Statement;
|
||||
markDelivered: Database.Statement;
|
||||
pendingByRecipient: Database.Statement;
|
||||
};
|
||||
|
||||
constructor(public readonly dbPath: string) {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma("journal_mode = WAL");
|
||||
this.db.pragma("foreign_keys = ON");
|
||||
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
|
||||
|
||||
this.stmts = {
|
||||
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
|
||||
insertMailbox: this.db.prepare(
|
||||
"INSERT INTO mailboxes (name, created_at, last_seen_at) VALUES (?, ?, ?)",
|
||||
),
|
||||
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
|
||||
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
|
||||
insertMessage: this.db.prepare(
|
||||
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
|
||||
),
|
||||
countPending: this.db.prepare(
|
||||
"SELECT COUNT(*) AS n FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL",
|
||||
),
|
||||
oldestPending: this.db.prepare(
|
||||
"SELECT created_at FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id LIMIT 1",
|
||||
),
|
||||
selectPending: this.db.prepare(
|
||||
"SELECT * FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id",
|
||||
),
|
||||
markDelivered: this.db.prepare(
|
||||
"UPDATE messages SET delivered_at = ? WHERE id IN (SELECT value FROM json_each(?))",
|
||||
),
|
||||
pendingByRecipient: this.db.prepare(
|
||||
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
upsertMailbox(name: string): void {
|
||||
const now = nowIso();
|
||||
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
|
||||
if (existing) {
|
||||
this.stmts.touchMailbox.run(now, name);
|
||||
} else {
|
||||
this.stmts.insertMailbox.run(name, now, now);
|
||||
}
|
||||
}
|
||||
|
||||
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.upsertMailbox(from);
|
||||
this.upsertMailbox(to);
|
||||
const createdAt = nowIso();
|
||||
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
|
||||
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
peek(name: string): InboxStatus {
|
||||
const row = this.stmts.countPending.get(name) as { n: number };
|
||||
if (row.n === 0) return { pending: 0, oldestAt: null };
|
||||
const oldest = this.stmts.oldestPending.get(name) as { created_at: string } | undefined;
|
||||
return { pending: row.n, oldestAt: parseDate(oldest?.created_at) };
|
||||
}
|
||||
|
||||
checkInbox(name: string): MessageRow[] {
|
||||
const tx = this.db.transaction(() => {
|
||||
const pending = this.stmts.selectPending.all(name) as MessageRow[];
|
||||
if (pending.length > 0) {
|
||||
const ids = pending.map((m) => m.id);
|
||||
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
|
||||
}
|
||||
return pending;
|
||||
});
|
||||
return tx();
|
||||
}
|
||||
|
||||
listMailboxes(forName?: string): MailboxInfo[] {
|
||||
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
|
||||
const pendingMap = new Map<string, number>();
|
||||
if (forName) {
|
||||
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
|
||||
for (const c of counts) pendingMap.set(c.to_mailbox, c.n);
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
lastSeenAt: parseDate(r.last_seen_at) ?? new Date(0),
|
||||
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function rowToMessage(r: MessageRow): {
|
||||
id: number;
|
||||
from: string;
|
||||
body: string;
|
||||
sentAt: Date;
|
||||
} {
|
||||
return {
|
||||
id: r.id,
|
||||
from: r.from_mailbox,
|
||||
body: r.body,
|
||||
sentAt: parseDate(r.created_at) ?? new Date(0),
|
||||
};
|
||||
}
|
||||
121
node/src/mcp.ts
Normal file
121
node/src/mcp.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { MailboxStore, rowToMessage } from "./db.js";
|
||||
import { HEADER_NAME } from "./server.js";
|
||||
|
||||
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.",
|
||||
inputSchema: {
|
||||
to: z.string().describe("Name of the recipient mailbox."),
|
||||
body: z.string().describe("Message body (plain text or markdown)."),
|
||||
},
|
||||
},
|
||||
async ({ to, body }, extra) => {
|
||||
const from = requireSender(extra);
|
||||
const r = store.send(from, to, body);
|
||||
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||
structuredContent: out,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"check_inbox",
|
||||
{
|
||||
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: {},
|
||||
},
|
||||
async (_args, extra) => {
|
||||
const name = requireSender(extra);
|
||||
const messages = store.checkInbox(name).map((m) => {
|
||||
const x = rowToMessage(m);
|
||||
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(messages) }],
|
||||
structuredContent: { messages },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"peek_inbox",
|
||||
{
|
||||
title: "Peek inbox",
|
||||
description:
|
||||
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async (_args, extra) => {
|
||||
const name = requireSender(extra);
|
||||
const status = store.peek(name);
|
||||
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(out) }],
|
||||
structuredContent: out,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_mailboxes",
|
||||
{
|
||||
title: "List mailboxes",
|
||||
description: "Discover known mailboxes and how many messages each has waiting for you.",
|
||||
inputSchema: {},
|
||||
},
|
||||
async (_args, extra) => {
|
||||
const name = requireSender(extra);
|
||||
const list = store.listMailboxes(name).map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
}));
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(list) }],
|
||||
structuredContent: { mailboxes: list },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
|
||||
const mcpServer = buildMcpServer(store);
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await mcpServer.connect(transport);
|
||||
|
||||
const handle = async (req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => {
|
||||
await transport.handleRequest(req.raw, reply.raw, req.body);
|
||||
};
|
||||
|
||||
app.post("/mcp", handle);
|
||||
app.get("/mcp", handle);
|
||||
app.delete("/mcp", handle);
|
||||
}
|
||||
113
node/src/server.ts
Normal file
113
node/src/server.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MailboxStore, rowToMessage } from "./db.js";
|
||||
import type { DaemonConfig } from "./config.js";
|
||||
import { registerMcp } from "./mcp.js";
|
||||
|
||||
export const HEADER_NAME = "x-mailbox";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
mailboxName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function readVersion(): string {
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
|
||||
version?: string;
|
||||
};
|
||||
return pkg.version ?? "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
|
||||
|
||||
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: true });
|
||||
const version = readVersion();
|
||||
|
||||
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const url = req.url.split("?")[0] ?? "/";
|
||||
if (url === "/health" || url === "/mcp" || url.startsWith("/mcp/")) return;
|
||||
|
||||
const headerValue = req.headers[HEADER_NAME];
|
||||
const name = (Array.isArray(headerValue) ? headerValue[0] : headerValue ?? "").trim();
|
||||
|
||||
if (!name) {
|
||||
if (ANONYMOUS_PATHS.has(url)) return;
|
||||
reply.code(400).send({ error: `Missing ${HEADER_NAME} header.` });
|
||||
return reply;
|
||||
}
|
||||
|
||||
req.mailboxName = name;
|
||||
store.upsertMailbox(name);
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
version,
|
||||
dbPath: cfg.dbPath,
|
||||
}));
|
||||
|
||||
app.post<{ Body: { to?: string; body?: string } }>("/v1/send", async (req, reply) => {
|
||||
const { to, body } = req.body ?? {};
|
||||
if (!to || !body) {
|
||||
reply.code(400);
|
||||
return { error: "to and body are required" };
|
||||
}
|
||||
const from = req.mailboxName!;
|
||||
const result = store.send(from, to, body);
|
||||
return { id: result.id, queuedAt: result.queuedAt.toISOString() };
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { name?: string } }>("/v1/peek", async (req, reply) => {
|
||||
const name = (req.query.name ?? "").trim();
|
||||
if (!name) {
|
||||
reply.code(400);
|
||||
return { error: "name is required" };
|
||||
}
|
||||
const status = store.peek(name);
|
||||
return {
|
||||
pending: status.pending,
|
||||
oldestAt: status.oldestAt?.toISOString() ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Querystring: { name?: string } }>("/v1/check-inbox", async (req, reply) => {
|
||||
const name = (req.query.name ?? "").trim();
|
||||
if (name !== req.mailboxName) {
|
||||
reply.code(403);
|
||||
return { error: "X-Mailbox header must match name." };
|
||||
}
|
||||
return store.checkInbox(name).map((m) => {
|
||||
const msg = rowToMessage(m);
|
||||
return { ...msg, sentAt: msg.sentAt.toISOString() };
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/v1/list", async (req) => {
|
||||
const name = req.mailboxName;
|
||||
return store.listMailboxes(name).map((m) => ({
|
||||
name: m.name,
|
||||
lastSeenAt: m.lastSeenAt.toISOString(),
|
||||
pendingForYou: m.pendingForYou,
|
||||
}));
|
||||
});
|
||||
|
||||
await registerMcp(app, store);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> {
|
||||
const store = new MailboxStore(cfg.dbPath);
|
||||
const app = await buildServer(cfg, store);
|
||||
await app.listen({ host: cfg.bind, port: cfg.port });
|
||||
return { app, store };
|
||||
}
|
||||
21
node/src/types/node-windows.d.ts
vendored
Normal file
21
node/src/types/node-windows.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
declare module "node-windows" {
|
||||
export interface ServiceOpts {
|
||||
name: string;
|
||||
description?: string;
|
||||
script: string;
|
||||
nodeOptions?: string[];
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export class Service {
|
||||
constructor(opts: ServiceOpts);
|
||||
on(
|
||||
event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop" | "error",
|
||||
cb: (err?: unknown) => void,
|
||||
): void;
|
||||
install(): void;
|
||||
uninstall(): void;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
}
|
||||
94
node/tests/db.test.ts
Normal file
94
node/tests/db.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-test-"));
|
||||
dbPath = join(dir, "test.db");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("schema", () => {
|
||||
it("creates fresh tables and is idempotent on re-open", () => {
|
||||
const a = new MailboxStore(dbPath);
|
||||
a.upsertMailbox("alice");
|
||||
a.close();
|
||||
|
||||
const b = new MailboxStore(dbPath);
|
||||
const list = b.listMailboxes();
|
||||
expect(list.map((m) => m.name)).toEqual(["alice"]);
|
||||
b.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("send / peek / check round-trip", () => {
|
||||
it("delivers a message exactly once", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
const result = store.send("alice", "bob", "hello bob");
|
||||
expect(result.id).toBeGreaterThan(0);
|
||||
|
||||
const peek1 = store.peek("bob");
|
||||
expect(peek1.pending).toBe(1);
|
||||
expect(peek1.oldestAt).toBeInstanceOf(Date);
|
||||
|
||||
const pulled = store.checkInbox("bob");
|
||||
expect(pulled).toHaveLength(1);
|
||||
expect(pulled[0]!.from_mailbox).toBe("alice");
|
||||
expect(pulled[0]!.body).toBe("hello bob");
|
||||
|
||||
const peek2 = store.peek("bob");
|
||||
expect(peek2.pending).toBe(0);
|
||||
expect(peek2.oldestAt).toBeNull();
|
||||
|
||||
const empty = store.checkInbox("bob");
|
||||
expect(empty).toEqual([]);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("checkInbox returns all pending in order and marks them delivered atomically", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.send("alice", "bob", `msg ${i}`);
|
||||
}
|
||||
const first = store.checkInbox("bob");
|
||||
expect(first).toHaveLength(10);
|
||||
expect(first.map((m) => m.body)).toEqual(
|
||||
Array.from({ length: 10 }, (_, i) => `msg ${i}`),
|
||||
);
|
||||
const second = store.checkInbox("bob");
|
||||
expect(second).toEqual([]);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMailboxes", () => {
|
||||
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
|
||||
const store = new MailboxStore(dbPath);
|
||||
try {
|
||||
store.send("alice", "bob", "x");
|
||||
store.send("alice", "bob", "y");
|
||||
store.send("carol", "bob", "z");
|
||||
|
||||
const fromBob = store.listMailboxes("bob");
|
||||
expect(fromBob.map((m) => m.name)).toEqual(["alice", "bob", "carol"]);
|
||||
const bobRow = fromBob.find((m) => m.name === "bob");
|
||||
expect(bobRow?.pendingForYou).toBe(3);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
109
node/tests/server.test.ts
Normal file
109
node/tests/server.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { MailboxStore } from "../src/db.js";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let store: MailboxStore;
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
|
||||
dbPath = join(dir, "test.db");
|
||||
store = new MailboxStore(dbPath);
|
||||
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store);
|
||||
await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
const addr = app.server.address();
|
||||
if (!addr || typeof addr === "string") throw new Error("no address");
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function call(
|
||||
method: string,
|
||||
path: string,
|
||||
init: { headers?: Record<string, string>; body?: unknown } = {},
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
|
||||
let body: string | undefined;
|
||||
if (init.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(init.body);
|
||||
}
|
||||
const res = await fetch(`${baseUrl}${path}`, { method, headers, body });
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text.length ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
describe("REST surface", () => {
|
||||
it("/health is anonymous", async () => {
|
||||
const r = await call("GET", "/health");
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toMatchObject({ status: "ok", dbPath });
|
||||
});
|
||||
|
||||
it("POST /v1/send requires X-Mailbox", async () => {
|
||||
const r = await call("POST", "/v1/send", { body: { to: "bob", body: "hi" } });
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
|
||||
it("POST /v1/send → /v1/check-inbox round-trip", async () => {
|
||||
const send = await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: { to: "bob", body: "hi bob" },
|
||||
});
|
||||
expect(send.status).toBe(200);
|
||||
expect(send.body).toMatchObject({ id: expect.any(Number), queuedAt: expect.any(String) });
|
||||
|
||||
const peek = await call("GET", "/v1/peek?name=bob");
|
||||
expect(peek.status).toBe(200);
|
||||
expect(peek.body).toMatchObject({ pending: 1 });
|
||||
|
||||
const check = await call("POST", "/v1/check-inbox?name=bob", {
|
||||
headers: { "X-Mailbox": "bob" },
|
||||
});
|
||||
expect(check.status).toBe(200);
|
||||
expect(Array.isArray(check.body)).toBe(true);
|
||||
const arr = check.body as Array<{ from: string; body: string }>;
|
||||
expect(arr).toHaveLength(1);
|
||||
expect(arr[0]!.from).toBe("alice");
|
||||
expect(arr[0]!.body).toBe("hi bob");
|
||||
|
||||
const peekAfter = await call("GET", "/v1/peek?name=bob");
|
||||
expect(peekAfter.body).toMatchObject({ pending: 0, oldestAt: null });
|
||||
});
|
||||
|
||||
it("POST /v1/check-inbox rejects mismatched X-Mailbox", async () => {
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: { to: "bob", body: "x" },
|
||||
});
|
||||
const wrong = await call("POST", "/v1/check-inbox?name=bob", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
});
|
||||
expect(wrong.status).toBe(403);
|
||||
});
|
||||
|
||||
it("/v1/list and /v1/peek are anonymous", async () => {
|
||||
await call("POST", "/v1/send", {
|
||||
headers: { "X-Mailbox": "alice" },
|
||||
body: { to: "bob", body: "x" },
|
||||
});
|
||||
const list = await call("GET", "/v1/list");
|
||||
expect(list.status).toBe(200);
|
||||
expect(Array.isArray(list.body)).toBe(true);
|
||||
|
||||
const peek = await call("GET", "/v1/peek?name=bob");
|
||||
expect(peek.status).toBe(200);
|
||||
});
|
||||
});
|
||||
24
node/tsconfig.json
Normal file
24
node/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
9
node/vitest.config.ts
Normal file
9
node/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 15_000,
|
||||
pool: "forks",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user