From 093cc05ff0d35bde511e5afa08e6da2c8be86ba1 Mon Sep 17 00:00:00 2001 From: Kazuhiro Homma Date: Fri, 12 Jun 2026 17:35:03 +0900 Subject: [PATCH] fix: start server when launched through npm bin symlink (npx) The v1.1.0 entrypoint guard compared path.resolve(process.argv[1]) with the real module path, but npm installs bin entries as symlinks (node_modules/.bin and the npx cache), so `npx @kazuph/mcp-taskmanager` silently exited without starting the server. Resolve argv[1] with realpathSync before comparing. Adds a regression test that launches the built entrypoint through an actual symlink (npx-style) and asserts the server starts and serves all 10 tools; verified red on the v1.1.0 guard and green on this fix. Co-Authored-By: Claude Fable 5 --- index.ts | 16 ++++-- package-lock.json | 4 +- package.json | 2 +- tests/bin-entrypoint.test.ts | 103 +++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/bin-entrypoint.test.ts diff --git a/index.ts b/index.ts index 4e226fca..6c64e722 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { Tool, ToolSchema, } from "@modelcontextprotocol/sdk/types.js"; +import { realpathSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as os from "node:os"; @@ -864,10 +865,17 @@ async function runServer() { } // Only start the stdio server when executed directly as a binary, not when -// imported by tests. process.argv[1] is the entrypoint script path. -const isMain = - process.argv[1] && - fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +// imported by tests. npm installs bin entries as SYMLINKS (node_modules/.bin, +// npx cache), so argv[1] must be realpath'd before comparing — path.resolve +// alone breaks `npx @kazuph/mcp-taskmanager`. +const isMain = (() => { + if (!process.argv[1]) return false; + try { + return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); + } catch { + return false; + } +})(); if (isMain) { runServer().catch((error) => { diff --git a/package-lock.json b/package-lock.json index 16a2ac5f..7a484cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kazuph/mcp-taskmanager", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kazuph/mcp-taskmanager", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.20.0", diff --git a/package.json b/package.json index 369d9fa8..fb2884cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kazuph/mcp-taskmanager", - "version": "1.1.0", + "version": "1.1.1", "description": "Model Context Protocol server for Task Management", "author": "kazuph (https://x.com/kazuph)", "main": "dist/index.js", diff --git a/tests/bin-entrypoint.test.ts b/tests/bin-entrypoint.test.ts new file mode 100644 index 00000000..fa62e918 --- /dev/null +++ b/tests/bin-entrypoint.test.ts @@ -0,0 +1,103 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// npm installs bin entries as symlinks (node_modules/.bin, the npx cache). +// v1.1.0 shipped an entrypoint guard that compared path.resolve(argv[1]) +// against the real module path, so the server silently never started when +// launched through a symlink. This test reproduces that exact launch shape. + +const distEntry = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../dist/index.js" +); + +let tmpDir: string; +let symlinkPath: string; + +function runServerOnce( + entry: string, + taskFile: string +): Promise<{ stdoutLines: string[]; stderr: string }> { + return new Promise((resolve, reject) => { + const p = spawn("node", [entry], { + env: { ...process.env, TASK_MANAGER_FILE_PATH: taskFile }, + }); + let out = ""; + let err = ""; + p.stdout.on("data", (d) => { + out += d.toString(); + }); + p.stderr.on("data", (d) => { + err += d.toString(); + }); + p.on("error", reject); + p.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "bin-test", version: "1.0" }, + }, + })}\n` + ); + setTimeout(() => { + p.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + })}\n` + ); + }, 300); + setTimeout(() => { + p.kill(); + resolve({ stdoutLines: out.trim().split("\n").filter(Boolean), stderr: err }); + }, 900); + }); +} + +describe("bin entrypoint", () => { + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-tm-bin-")); + symlinkPath = path.join(tmpDir, "mcp-taskmanager"); + await fs.symlink(distEntry, symlinkPath); + }); + + afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("starts the server when launched through a symlink (npx-style)", async () => { + const { stdoutLines, stderr } = await runServerOnce( + symlinkPath, + path.join(tmpDir, "via-symlink", "tasks.json") + ); + expect(stderr).toContain("Task Manager MCP Server running"); + const tools = stdoutLines + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .find((m) => m && m.id === 2); + expect(tools?.result?.tools?.length).toBe(10); + }); + + it("starts the server when launched via the direct path", async () => { + const { stderr } = await runServerOnce( + distEntry, + path.join(tmpDir, "direct", "tasks.json") + ); + expect(stderr).toContain("Task Manager MCP Server running"); + }); +});