Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
},
"homepage": "https://github.com/chrisdoc/hevy-mcp#readme",
"bin": {
"hevy-mcp": "dist/index.js"
"hevy-mcp": "dist/cli.js"
},
"scripts": {
"inspect": "pnpm run build && pnpm dlx @modelcontextprotocol/inspector@latest node dist/index.js",
"inspect": "pnpm run build && pnpm dlx @modelcontextprotocol/inspector@latest node dist/cli.js",
"test": "vitest --run",
"export-specs": "node ./scripts/export-openapi-spec.js",
"build": "tsup",
"build:client": "kubb generate",
"start": "node dist/index.js",
"dev": "tsx watch --clear-screen=false src/index.ts",
"start": "node dist/cli.js",
"dev": "tsx watch --clear-screen=false src/cli.ts",
"smithery:build": "smithery build",
"smithery:dev": "smithery dev",
"check": "biome check --write --unsafe",
Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { runServer } from "./index.js";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/cli.ts is TypeScript source but imports "./index.js", which bakes the emitted .js extension into the source path. That’s brittle against future changes in build configuration (e.g., different extension, multiple output formats, or non-relative resolution) and makes this file behave differently under tsx/ts-node vs compiled output. Since the rest of the codebase uses extension-less internal imports in TS (e.g., "./utils/hevyClient.js" in tests is fine because it only runs under Node+ts-node/tsx test environment), this one stands out as a maintainability risk for the CLI entrypoint specifically.

Using the module name without the extension ("./index") keeps the TS source decoupled from a specific JS extension while still resolving correctly through tsup and Node’s ESM loader in the built output.

Suggestion

Consider changing the import in src/cli.ts to avoid hard-coding the .js extension, so the TS source does not depend on the emitted filename details:

import { runServer } from "./index";

This keeps the CLI entry resilient if you later tweak tsup output options (e.g., different extension or dual CJS/ESM builds). Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.


void runServer().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
106 changes: 103 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,57 @@
import { describe, expect, it } from "vitest";
import createServer, { configSchema } from "./index.js";
import * as stdioModule from "@modelcontextprotocol/sdk/server/stdio.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import createServer, { configSchema, runServer } from "./index.js";
import { createClient } from "./utils/hevyClient.js";

const originalEnv = { ...process.env };
const originalArgv = [...process.argv];

vi.mock("./utils/hevyClient.js", () => ({
createClient: vi.fn().mockReturnValue({ mockedClient: true }),
}));

vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => {
class MockMcpServer {
server = { mockServer: true };
connect = vi.fn().mockResolvedValue(undefined);
tool = vi.fn();
}

return {
McpServer: MockMcpServer,
};
});

vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => {
const transports: unknown[] = [];
class MockStdioServerTransport {
constructor() {
transports.push(this);
}
}

return {
StdioServerTransport: MockStdioServerTransport,
__transports: transports,
};
});

describe("Server entry", () => {
beforeEach(() => {
process.env = { ...originalEnv };
process.argv = [...originalArgv];
vi.clearAllMocks();
const anyStdioModule = stdioModule as { __transports?: unknown[] };
if (anyStdioModule.__transports) {
anyStdioModule.__transports.length = 0;
}
});

afterEach(() => {
process.env = { ...originalEnv };
process.argv = [...originalArgv];
});

describe("Smithery exports", () => {
it("validates HEVY_API_KEY via configSchema", () => {
expect(() => configSchema.parse({ apiKey: "" })).toThrow();
const parsed = configSchema.parse({ apiKey: "abc" });
Expand All @@ -12,4 +62,54 @@ describe("Smithery exports", () => {
const server = createServer({ config: { apiKey: "test-key" } });
expect(server).toBeDefined();
});

describe("runServer", () => {
it("uses HEVY_API_KEY from the environment and connects stdio transport", async () => {
process.env = {
...originalEnv,
HEVY_API_KEY: "test-api-key",
};
process.argv = originalArgv.slice(0, 2);

await runServer();
expect(createClient).toHaveBeenCalledWith(
"test-api-key",
"https://api.hevyapp.com",
);
const anyStdioModule = stdioModule as { __transports?: unknown[] };
expect(anyStdioModule.__transports?.length).toBeGreaterThan(0);
});

it("prefers CLI --hevy-api-key argument over environment variable", async () => {
process.env = {
...originalEnv,
HEVY_API_KEY: "env-key",
};
process.argv = [...originalArgv.slice(0, 2), "--hevy-api-key=cli-key"];

await runServer();
expect(createClient).toHaveBeenCalledWith(
"cli-key",
"https://api.hevyapp.com",
);
});

it("exits the process when no API key is provided", async () => {
process.env = {
...originalEnv,
HEVY_API_KEY: "",
};
process.argv = originalArgv.slice(0, 2);

const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((_code?: number) => {
throw new Error("process.exit called");
});

await expect(runServer()).rejects.toThrow();
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
});
});
25 changes: 1 addition & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { fileURLToPath } from "node:url";
import dotenvx from "@dotenvx/dotenvx";

// Configure dotenvx with quiet mode to prevent stdout pollution in stdio mode
Expand Down Expand Up @@ -52,7 +51,7 @@ export default function createServer({ config }: { config: ServerConfig }) {
return server.server;
}

async function runServer() {
export async function runServer() {
const args = process.argv.slice(2);
const cfg = parseConfig(args, process.env);
const apiKey = cfg.apiKey;
Expand All @@ -63,25 +62,3 @@ async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
}

const isDirectExecution = (() => {
if (typeof process === "undefined" || !Array.isArray(process.argv)) {
return false;
}
if (typeof import.meta === "undefined" || !import.meta?.url) {
return false;
}
try {
const modulePath = fileURLToPath(import.meta.url);
return process.argv[1] === modulePath;
} catch {
return false;
}
})();

if (isDirectExecution) {
runServer().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
}
2 changes: 1 addition & 1 deletion tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/index.ts"],
entry: ["src/index.ts", "src/cli.ts"],
format: ["esm"],
target: "esnext",
sourcemap: true,
Expand Down
Loading