Skip to content
Open
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
105 changes: 65 additions & 40 deletions mcp-server/src/cli-bin.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
/**
* Packaged **CLI** integration tests: the `bin` field and `npx` behavior for a
* tarball shaped like a publish (not the programmatic `exports` entry; see
* `package-published-esm.integration.test.ts` for `import "@currents/mcp"`).
*

* 1. Prerequisite: `dist/index.mjs` exists (`npm run test:run` runs `build`
* first). If missing, the suite is skipped so `vitest` without a prior
* build does not fail noisily.
* 2. `packTarball`: `npm pack` from the package root → one `.tgz` under a
* temp dir. Contents follow `package.json` `files` and npm’s pack rules
* (same artifact shape as registry install, minus release-only publish.cjs
* mutations).
*/
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
Expand All @@ -21,71 +7,79 @@ import { describe, expect, it } from "vitest";

const root = fileURLToPath(new URL("..", import.meta.url));
const buildIndex = path.join(root, "dist", "index.mjs");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";

function packTarball(packDest: string): string {
// Respect `files` and standard pack rules; do not mutate package.json (unlike release `publish.cjs`).
execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", packDest], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
});
execFileSync(
npmCommand,
["pack", "--ignore-scripts", "--pack-destination", packDest],
{
cwd: root,
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
},
);
const tgz = readdirSync(packDest).filter((f) => f.endsWith(".tgz"));
if (tgz.length !== 1) {
throw new Error(`expected one .tgz in ${packDest}, got: ${tgz.join(", ")}`);
}
return path.join(packDest, tgz[0]);
}

function npmBinPath(installDir: string, name: string): string {
return path.join(
installDir,
"node_modules",
".bin",
process.platform === "win32" ? `${name}.cmd` : name,
);
}

function cliEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.CURRENTS_API_KEY;
return env;
}

describe.skipIf(!existsSync(buildIndex))(
"packaged CLI (npx / bin)",
{ timeout: 60_000 },
() => {
/**
* `npx -y --package <abs-path-to.tgz> mcp`: npm treats the tarball as the
* package to install transiently; `mcp` is the bin name from that package’s
* `package.json` `bin` map (not the scoped package name). The child should
* start the MCP server and log the “live” line (stdio MCP servers run until
* stdin closes; we cap wall time with `spawnSync` timeout). Accept either
* that log line or process timeout as success so slow CI still passes.
* */
it("starts via npx --package tarball mcp", () => {
const packDir = mkdtempSync(path.join(tmpdir(), "mcp-pack-"));
const tarball = packTarball(packDir);
const r = spawnSync("npx", ["-y", "--package", tarball, "mcp"], {
const r = spawnSync(npxCommand, ["-y", "--package", tarball, "mcp"], {
cwd: packDir,
timeout: 45_000,
encoding: "utf-8",
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
// Required for server startup; value is unused in this smoke test.
CURRENTS_API_KEY: "vitest-cli-pack-smoke",
},
});
const combined = `${r.stdout ?? ""}${r.stderr ?? ""}`;
const timedOut =
r.error != null && "code" in r.error && r.error.code === "ETIMEDOUT";
expect(combined.includes("Currents MCP Server is live") || timedOut).toBe(
true
true,
);
});

/*
* Second `it` — consumer project + `node_modules/.bin`:
* - `npm init -y` and `npm install <tgz>` in a fresh temp project. npm links
* `node_modules/.bin/mcp` (or `mcp.cmd` on Windows) to the packed CLI.
* - Assert the shim exists. This catches broken `bin`, wrong `files` (missing
* `dist/index.mjs`), or install layout issues without spawning the server.
* */
it("exposes mcp bin after npm install from tarball", () => {
const packDir = mkdtempSync(path.join(tmpdir(), "mcp-pack-"));
const installDir = mkdtempSync(path.join(tmpdir(), "mcp-install-"));
const tarball = packTarball(packDir);
execFileSync("npm", ["init", "-y"], {
execFileSync(npmCommand, ["init", "-y"], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});
execFileSync("npm", ["install", tarball], {
execFileSync(npmCommand, ["install", tarball], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});
const binDir = path.join(installDir, "node_modules", ".bin");
Expand All @@ -94,5 +88,36 @@ describe.skipIf(!existsSync(buildIndex))(
existsSync(path.join(binDir, "mcp.cmd"));
expect(hasMcp).toBe(true);
});
}

it.each([
["--help", "Usage: mcp [options]"],
["--version", "2.3.2"],
])("handles %s without CURRENTS_API_KEY", (flag, expected) => {
const packDir = mkdtempSync(path.join(tmpdir(), "mcp-pack-"));
const installDir = mkdtempSync(path.join(tmpdir(), "mcp-install-"));
const tarball = packTarball(packDir);
execFileSync(npmCommand, ["init", "-y"], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});
execFileSync(npmCommand, ["install", tarball], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});

const r = spawnSync(npmBinPath(installDir, "mcp"), [flag], {
cwd: installDir,
encoding: "utf-8",
env: cliEnv(),
shell: process.platform === "win32",
});
const combined = `${r.stdout ?? ""}${r.stderr ?? ""}`;

expect(r.status).toBe(0);
expect(combined).toContain(expected);
expect(combined).not.toContain("CURRENTS_API_KEY");
});
},
);
85 changes: 85 additions & 0 deletions mcp-server/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { handleCliMetadataFlags } from "./cli.js";

function capture() {
let text = "";
return {
stream: {
write(chunk: string | Uint8Array) {
text += chunk.toString();
return true;
},
},
get text() {
return text;
},
};
}

describe("handleCliMetadataFlags", () => {
it.each(["--help", "-h"])("prints help for %s", (flag) => {
const out = capture();
const err = capture();

const result = handleCliMetadataFlags(
["node", "dist/index.mjs", flag],
"2.3.2",
out.stream,
err.stream,
);

expect(result).toEqual({ handled: true, exitCode: 0 });
expect(out.text).toContain("Usage: mcp [options]");
expect(out.text).toContain("--version");
expect(err.text).toBe("");
});

it.each(["--version", "-v"])("prints version for %s", (flag) => {
const out = capture();
const err = capture();

const result = handleCliMetadataFlags(
["node", "dist/index.mjs", flag],
"2.3.2",
out.stream,
err.stream,
);

expect(result).toEqual({ handled: true, exitCode: 0 });
expect(out.text).toBe("2.3.2\n");
expect(err.text).toBe("");
});

it("returns control for server startup without CLI metadata flags", () => {
const out = capture();
const err = capture();

const result = handleCliMetadataFlags(
["node", "dist/index.mjs"],
"2.3.2",
out.stream,
err.stream,
);

expect(result).toEqual({ handled: false });
expect(out.text).toBe("");
expect(err.text).toBe("");
});

it("reports unknown top-level options without starting the server", () => {
const out = capture();
const err = capture();

const result = handleCliMetadataFlags(
["node", "dist/index.mjs", "--definitely-not-real"],
"2.3.2",
out.stream,
err.stream,
);

expect(result).toEqual({ handled: true, exitCode: 1 });
expect(out.text).toBe("");
expect(err.text).toContain("Unknown option: --definitely-not-real");
expect(err.text).toContain("Usage: mcp [options]");
});
});
45 changes: 45 additions & 0 deletions mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type CliResult =
| { handled: true; exitCode: number }
| { handled: false };

export function handleCliMetadataFlags(
argv: string[],
version: string,
out: Pick<NodeJS.WriteStream, "write"> = process.stdout,
err: Pick<NodeJS.WriteStream, "write"> = process.stderr,
): CliResult {
const args = argv.slice(2);

if (args.length === 0) {
return { handled: false };
}

if (args.includes("--help") || args.includes("-h")) {
out.write(getHelpText());
return { handled: true, exitCode: 0 };
}

if (args.includes("--version") || args.includes("-v")) {
out.write(`${version}\n`);
return { handled: true, exitCode: 0 };
}

const unknownOption = args.find((arg) => arg.startsWith("-"));
if (unknownOption) {
err.write(`Unknown option: ${unknownOption}\n\n${getHelpText()}`);
return { handled: true, exitCode: 1 };
}

return { handled: false };
}

function getHelpText(): string {
return `Usage: mcp [options]

Currents MCP server over stdio.

Options:
-h, --help Display help for command
-v, --version Display package version
`;
}
8 changes: 8 additions & 0 deletions mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
#!/usr/bin/env node
import { handleCliMetadataFlags } from "./cli.js";
import { MISSING_CURRENTS_API_KEY_MESSAGE } from "./lib/env.js";
import { logger } from "./lib/logger.js";
import { startMcpServer } from "./server.js";

declare const __VERSION__: string;

const cliResult = handleCliMetadataFlags(process.argv, __VERSION__);
if (cliResult.handled) {
process.exit(cliResult.exitCode);
}

startMcpServer().catch((error) => {
if (
error instanceof Error &&
Expand Down
20 changes: 14 additions & 6 deletions mcp-server/src/package-published-esm.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,20 @@ import { describe, expect, it } from "vitest";

const root = fileURLToPath(new URL("..", import.meta.url));
const buildIndex = path.join(root, "dist", "index.mjs");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";

/** Run `npm pack` from the package root and return the path to the single `.tgz` in `packDest`. */
function packTarball(packDest: string): string {
// Respect `files` and standard pack rules; do not mutate package.json (unlike release `publish.cjs`).
execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", packDest], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
});
execFileSync(
npmCommand,
["pack", "--ignore-scripts", "--pack-destination", packDest],
{
cwd: root,
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
},
);
const tgz = readdirSync(packDest).filter((f) => f.endsWith(".tgz"));
if (tgz.length !== 1) {
throw new Error(`expected one .tgz in ${packDest}, got: ${tgz.join(", ")}`);
Expand All @@ -62,12 +68,14 @@ describe.skipIf(!existsSync(buildIndex))(
path.join(tmpdir(), "mcp-install-published-")
);
const tarball = packTarball(packDir);
execFileSync("npm", ["init", "-y"], {
execFileSync(npmCommand, ["init", "-y"], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});
execFileSync("npm", ["install", tarball], {
execFileSync(npmCommand, ["install", tarball], {
cwd: installDir,
shell: process.platform === "win32",
stdio: "ignore",
});
const fixture = path.join(
Expand Down