diff --git a/README.md b/README.md index 40f7949..bf23c69 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ claude-mailbox uninstall-autostart [--purge] | Platform | Default mechanism | `--service` mechanism | |---|---|---| -| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) | +| Windows | Scheduled Task at logon (no admin); falls back to HKCU Run-key if Group Policy blocks schtasks | Windows Service (admin, via `node-windows`) | | macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a | | Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a | diff --git a/node/package-lock.json b/node/package-lock.json index dbad1da..fea6e17 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kuns/claude-mailbox", - "version": "0.1.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kuns/claude-mailbox", - "version": "0.1.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/node/package.json b/node/package.json index 7488ab6..7076160 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@kuns/claude-mailbox", - "version": "0.1.0", + "version": "1.0.1", "description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.", "type": "module", "bin": { diff --git a/node/src/autostart/windows.ts b/node/src/autostart/windows.ts index ea7f265..a3f4729 100644 --- a/node/src/autostart/windows.ts +++ b/node/src/autostart/windows.ts @@ -1,10 +1,40 @@ -import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js"; import { userConfigPath } from "../config.js"; +function markerPath(): string { + return join(dirname(userConfigPath()), MARKER_FILE); +} + +function readActiveMode(): "task" | "run-key" | null { + const path = markerPath(); + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8").trim(); + if (raw === "task" || raw === "run-key") return raw; + return null; +} + +function writeActiveMode(mode: "task" | "run-key"): void { + const path = markerPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, mode, "utf8"); +} + +function clearActiveMode(): void { + const path = markerPath(); + if (existsSync(path)) rmSync(path, { force: true }); +} + +function isAccessDenied(stderr: string): boolean { + return /access is denied|0x80070005/i.test(stderr); +} + const TASK_NAME = "ClaudeMailbox"; const SERVICE_NAME = "ClaudeMailbox"; +const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const RUN_VALUE = "ClaudeMailbox"; +const MARKER_FILE = "autostart-mode"; function ensureConfigSeeded(opts: AutostartInstallOpts): string { const path = userConfigPath(); @@ -24,10 +54,14 @@ function buildServeCommand(): { node: string; script: string; configPath: string return { node, script, configPath: userConfigPath() }; } -function scheduledTaskInstall(opts: AutostartInstallOpts): void { - const configPath = ensureConfigSeeded(opts); +function buildServeCommandString(configPath: string): string { const { node, script } = buildServeCommand(); - const tr = `"${node}" "${script}" serve --config "${configPath}"`; + return `"${node}" "${script}" serve --config "${configPath}"`; +} + +function tryScheduledTaskInstall(opts: AutostartInstallOpts): { ok: boolean; stderr: string } { + const configPath = ensureConfigSeeded(opts); + const tr = buildServeCommandString(configPath); const r = run("schtasks.exe", [ "/Create", "/SC", @@ -40,28 +74,110 @@ function scheduledTaskInstall(opts: AutostartInstallOpts): void { "LIMITED", "/F", ]); + if (r.status !== 0) return { ok: false, stderr: r.stderr || r.stdout }; + run("schtasks.exe", ["/Run", "/TN", TASK_NAME]); + return { ok: true, stderr: "" }; +} + +function runKeyInstall(opts: AutostartInstallOpts): void { + const configPath = ensureConfigSeeded(opts); + const cmd = buildServeCommandString(configPath); + const r = run("reg.exe", [ + "add", + RUN_KEY, + "/v", + RUN_VALUE, + "/t", + "REG_SZ", + "/d", + cmd, + "/f", + ]); if (r.status !== 0) { - throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`); + throw new Error(`reg add (HKCU Run) 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()}`); + spawnRunKeyDaemon(configPath); +} + +function spawnRunKeyDaemon(configPath: string): void { + if (runKeyDaemonRunning()) return; + const { node, script } = buildServeCommand(); + const ps = `Start-Process -WindowStyle Hidden -FilePath "${node}" -ArgumentList @('"${script}"','serve','--config','"${configPath}"')`; + run("powershell.exe", ["-NoProfile", "-Command", ps]); +} + +function runKeyDaemonRunning(): boolean { + const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | Select-Object -First 1 -ExpandProperty ProcessId`; + const r = run("powershell.exe", ["-NoProfile", "-Command", ps]); + return r.status === 0 && r.stdout.trim().length > 0; +} + +function killRunKeyDaemon(): void { + const ps = `Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*claude-mailbox*serve*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`; + run("powershell.exe", ["-NoProfile", "-Command", ps]); +} + +function runKeyUninstall(): void { + const r = run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]); + if (r.status !== 0 && !/unable to find/i.test(r.stderr)) { + throw new Error(`reg delete failed (exit ${r.status}): ${r.stderr || r.stdout}`); } + killRunKeyDaemon(); +} + +function scheduledTaskInstall(opts: AutostartInstallOpts): void { + const attempt = tryScheduledTaskInstall(opts); + if (attempt.ok) { + writeActiveMode("task"); + return; + } + if (isAccessDenied(attempt.stderr)) { + console.warn( + "schtasks /Create denied by Windows policy — falling back to HKCU Run-key autostart (per-user, no admin).", + ); + runKeyInstall(opts); + writeActiveMode("run-key"); + return; + } + throw new Error(`schtasks /Create failed: ${attempt.stderr}`); } function scheduledTaskUninstall(purge: boolean): void { + const mode = readActiveMode(); + if (mode === "run-key") { + runKeyUninstall(); + clearActiveMode(); + if (purge) purgeData(); + return; + } + // Default to task uninstall, also clean up Run-key in case of mixed state 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 (r.status !== 0 && !/cannot find/i.test(r.stderr) && !/does not exist/i.test(r.stderr)) { + // Fall through — try Run-key cleanup anyway } + // Best-effort Run-key cleanup + run("reg.exe", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"]); + killRunKeyDaemon(); + clearActiveMode(); if (purge) purgeData(); } function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" { + const mode = readActiveMode(); + if (mode === "run-key") { + const r = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]); + if (r.status !== 0) return "NotInstalled"; + return runKeyDaemonRunning() ? "Running" : "Stopped"; + } 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"; + if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) { + // Maybe a Run-key install happened without a marker (legacy / manual). Check reg. + const reg = run("reg.exe", ["query", RUN_KEY, "/v", RUN_VALUE]); + if (reg.status === 0) return runKeyDaemonRunning() ? "Running" : "Stopped"; + return "NotInstalled"; + } return "Stopped"; } if (/Status:\s*Running/i.test(r.stdout)) return "Running"; @@ -69,11 +185,22 @@ function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" { } function scheduledTaskRun(): void { + const mode = readActiveMode(); + if (mode === "run-key") { + const cfgPath = userConfigPath(); + spawnRunKeyDaemon(cfgPath); + return; + } 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 { + const mode = readActiveMode(); + if (mode === "run-key") { + killRunKeyDaemon(); + return; + } run("schtasks.exe", ["/End", "/TN", TASK_NAME]); } diff --git a/node/src/cli.ts b/node/src/cli.ts index 94919e9..2cde8ea 100644 --- a/node/src/cli.ts +++ b/node/src/cli.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js"; import { startServer } from "./server.js"; import { autostartManager } from "./autostart/index.js"; +import { runStdioMcp } from "./mcp-stdio.js"; import { applyInstall, applyUninstall, @@ -267,6 +268,20 @@ program } }); +program + .command("mcp-stdio") + .description( + "Run a stdio MCP server that proxies tool calls to the local daemon's REST API. The daemon URL comes from $CLAUDE_MAILBOX_URL (default http://127.0.0.1:47822). Used by the Claude Code plugin's .mcp.json so the URL is configurable per machine without env-substitution in the URL field.", + ) + .action(async () => { + try { + await runStdioMcp(); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + }); + program .command("install-hook") .description( diff --git a/node/src/mcp-stdio.ts b/node/src/mcp-stdio.ts new file mode 100644 index 0000000..b8340cf --- /dev/null +++ b/node/src/mcp-stdio.ts @@ -0,0 +1,165 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { DEFAULT_PORT } from "./config.js"; + +const HARDCODED_DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`; + +function resolveDaemonUrl(): string { + const env = (process.env["CLAUDE_MAILBOX_URL"] ?? "").trim(); + if (!env || env.includes("${")) return HARDCODED_DEFAULT_URL; + return env.replace(/\/$/, ""); +} + +function requireIdentity(value: string | undefined, argName: "from" | "name"): string { + const v = (value ?? "").trim(); + if (!v) { + throw new Error( + `Pass \`${argName}\` (your mailbox name from the SessionStart announcement).`, + ); + } + return v; +} + +async function rest( + method: "GET" | "POST", + url: string, + init: { headers?: Record; body?: unknown } = {}, +): Promise { + const headers: Record = { 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; +} + +export function buildStdioMcpServer(daemonUrl: string = resolveDaemonUrl()): McpServer { + const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" }); + + server.registerTool( + "send", + { + title: "Send mail", + description: + "Send a message to another mailbox. Pass `from` with your own mailbox name (see the SessionStart announcement).", + inputSchema: { + to: z.string().describe("Name of the recipient mailbox."), + body: z.string().describe("Message body (plain text or markdown)."), + from: z + .string() + .describe( + "Your mailbox name. Take it from the SessionStart announcement.", + ), + }, + }, + async ({ to, body, from }) => { + const sender = requireIdentity(from, "from"); + const out = (await rest("POST", `${daemonUrl}/v1/send`, { + headers: { "X-Mailbox": sender }, + body: { to, body }, + })) as { id: number; queuedAt: string }; + return { + content: [{ type: "text", text: JSON.stringify(out) }], + structuredContent: out, + }; + }, + ); + + server.registerTool( + "check_inbox", + { + title: "Check inbox", + description: + "Pull all undelivered messages for your mailbox and mark them delivered. Pass `name` with your own mailbox name.", + inputSchema: { + name: z + .string() + .describe( + "Your mailbox name. Take it from the SessionStart announcement.", + ), + }, + }, + async ({ name }) => { + const me = requireIdentity(name, "name"); + const messages = (await rest( + "POST", + `${daemonUrl}/v1/check-inbox?name=${encodeURIComponent(me)}`, + { headers: { "X-Mailbox": me } }, + )) as { id: number; from: string; body: string; sentAt: string }[]; + const arr = Array.isArray(messages) ? messages : []; + return { + content: [{ type: "text", text: JSON.stringify(arr) }], + structuredContent: { messages: arr }, + }; + }, + ); + + server.registerTool( + "peek_inbox", + { + title: "Peek inbox", + description: + "Non-consuming check of your mailbox. Returns pending count and oldest pending timestamp. Pass `name` with your own mailbox name.", + inputSchema: { + name: z + .string() + .describe( + "Your mailbox name. Take it from the SessionStart announcement.", + ), + }, + }, + async ({ name }) => { + const me = requireIdentity(name, "name"); + const out = (await rest("GET", `${daemonUrl}/v1/peek?name=${encodeURIComponent(me)}`)) as { + pending: number; + oldestAt: string | 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. Pass `name` with your own mailbox name.", + inputSchema: { + name: z + .string() + .describe( + "Your mailbox name. Take it from the SessionStart announcement.", + ), + }, + }, + async ({ name }) => { + const me = requireIdentity(name, "name"); + const list = (await rest("GET", `${daemonUrl}/v1/list`, { + headers: { "X-Mailbox": me }, + })) as { name: string; lastSeenAt: string; pendingForYou: number }[]; + const arr = Array.isArray(list) ? list : []; + return { + content: [{ type: "text", text: JSON.stringify(arr) }], + structuredContent: { mailboxes: arr }, + }; + }, + ); + + return server; +} + +export async function runStdioMcp(): Promise { + const server = buildStdioMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json index 59104dc..eebfae6 100644 --- a/plugin/.mcp.json +++ b/plugin/.mcp.json @@ -1,8 +1,9 @@ { "mcpServers": { "mailbox": { - "type": "http", - "url": "http://127.0.0.1:47822/mcp" + "type": "stdio", + "command": "claude-mailbox", + "args": ["mcp-stdio"] } } } diff --git a/plugin/README.md b/plugin/README.md index d04808f..75ee1b0 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -44,7 +44,9 @@ 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): +The plugin ships a `.mcp.json` that spawns a **stdio MCP wrapper** (`claude-mailbox mcp-stdio`) so the daemon URL is configurable per machine via the `CLAUDE_MAILBOX_URL` env var (Claude Code doesn't yet support env substitution in HTTP MCP URLs — see issue #46889). The wrapper proxies tool calls to the daemon's REST API. + +Each MCP tool takes the caller's mailbox name as an explicit argument (from the SessionStart announcement): | Tool | Required args | Purpose | |---|---|---| diff --git a/plugin/commands/mailbox-doctor.md b/plugin/commands/mailbox-doctor.md index 5bcfd4d..789affa 100644 --- a/plugin/commands/mailbox-doctor.md +++ b/plugin/commands/mailbox-doctor.md @@ -59,11 +59,9 @@ Run: `claude-mailbox status` - `Stopped` → `claude-mailbox start`, re-check. - `NotInstalled` → `claude-mailbox install-autostart`, then `claude-mailbox start`, re-check. -**If `install-autostart` fails with "Access is denied" on Windows:** Group Policy may block non-admin `schtasks /Create`. Two fallbacks: -1. Tell the user to run `claude-mailbox install-autostart` from an elevated PowerShell themselves (one-time). -2. For this session, run `claude-mailbox serve` as a background process so the rest of the doctor's checks can pass — the daemon won't survive logoff, but that's fine for verification. +**Behavior on `install-autostart`:** The CLI tries a Scheduled Task first (`schtasks /RL LIMITED`, no admin). If Windows Group Policy returns "Access is denied", it falls back transparently to an `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` registry entry plus a hidden `node serve` process — same per-user persistence, no admin needed. The chosen mechanism is recorded in `~/.claude-mailbox/autostart-mode` and respected by `status`/`start`/`stop`/`uninstall-autostart`. -If status doesn't reach `Running` after the fallback, stop and report. +If `install-autostart` still fails after both attempts (very rare — would mean both `schtasks` and `reg add` are blocked), stop and report what `status` and `start` printed. ## Step 4 — health probe