feat: stdio MCP wrapper + Windows Run-key autostart fallback (v1.0.1)
Two production-readiness fixes so colleagues can install cleanly: 1. Plugin's MCP server now spawns `claude-mailbox mcp-stdio`, a small stdio MCP wrapper that proxies tool calls to the daemon's REST API. Claude Code does not support env-var substitution in HTTP MCP `url` fields (issue #46889), so the wrapper is the only way to make the daemon URL configurable per machine via CLAUDE_MAILBOX_URL. 2. Windows `install-autostart` now falls back from `schtasks /Create` to an HKCU\Software\Microsoft\Windows\CurrentVersion\Run entry when Group Policy blocks the Scheduled Task path. Both modes are per-user, no admin, persist across logoffs. The chosen mode is recorded in ~/.claude-mailbox/autostart-mode so status/start/stop/ uninstall-autostart pick the right cleanup path. Also bumps the npm version to 1.0.1 to align with the published 1.0.0 plus this patch.
This commit is contained in:
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
165
node/src/mcp-stdio.ts
Normal file
165
node/src/mcp-stdio.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
const server = buildStdioMcpServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
Reference in New Issue
Block a user