ts-mcp-template/tests/stdio.test.ts
Jax 37d94c669a test: verify published stdio entry behavior
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 18:37:42 +08:00

151 lines
5.1 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { describe, expect, it } from "vitest";
import { EXAMPLE_ECHO_TOOL_NAME } from "../src/capabilities/index.js";
const testsDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(testsDir, "..");
const stdioEntryPath = resolve(projectRoot, "src/stdio.ts");
const packageJsonPath = resolve(projectRoot, "package.json");
type StdioCommandConfig = {
name?: string;
bin?: Record<string, string>;
mcp?: {
stdio?: {
developmentOnly?: boolean;
command?: string;
args?: string[];
};
};
scripts?: Record<string, string>;
};
async function readStdioCommandConfig(): Promise<{ command: string; args: string[] }> {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
return {
command: packageJson.mcp?.stdio?.command ?? "node",
args: packageJson.mcp?.stdio?.args ?? [],
};
}
async function createConnectedStdioClient(): Promise<{
client: Client;
transport: StdioClientTransport;
}> {
const stdioCommand = await readStdioCommandConfig();
const transport = new StdioClientTransport({
command: stdioCommand.command,
args: stdioCommand.args,
cwd: projectRoot,
stderr: "pipe",
});
const client = new Client(
{ name: "stdio-test-client", version: "0.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
return { client, transport };
}
describe("stdio entrypoint", () => {
it("starts over stdio and keeps startup diagnostics on stderr", async () => {
const stdioCommand = await readStdioCommandConfig();
const transport = new StdioClientTransport({
command: stdioCommand.command,
args: stdioCommand.args,
cwd: projectRoot,
stderr: "pipe",
});
const stderrMessages: string[] = [];
const stderr = transport.stderr;
stderr?.on("data", (chunk) => {
stderrMessages.push(chunk.toString());
});
const client = new Client(
{ name: "stdio-test-client", version: "0.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain(EXAMPLE_ECHO_TOOL_NAME);
expect(stderrMessages.join("")).toContain("MCP stdio server started");
await client.close();
await transport.close();
});
it("rejects invalid tool calls over stdio transport", async () => {
const { client, transport } = await createConnectedStdioClient();
const missingToolResult = await client.callTool({
name: "template.missing-tool",
arguments: {},
});
const invalidInputResult = await client.callTool({
name: EXAMPLE_ECHO_TOOL_NAME,
arguments: { uppercase: true },
});
expect(missingToolResult.isError).toBe(true);
expect(JSON.stringify(missingToolResult.content)).toMatch(/not found|unknown/i);
expect(invalidInputResult.isError).toBe(true);
expect(JSON.stringify(invalidInputResult.content)).toMatch(/message|required|invalid/i);
await client.close();
await transport.close();
});
it("publishes a development-only non-npm stdio launch command for workspace MCP clients", async () => {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
expect(packageJson.mcp?.stdio?.developmentOnly).toBe(true);
expect(packageJson.mcp?.stdio?.command).not.toMatch(/^npm|^npx/);
expect(packageJson.scripts?.["start:stdio"]).toBeUndefined();
expect(packageJson.mcp?.stdio?.command).toBe("node");
expect(packageJson.mcp?.stdio?.args).toEqual([
"./node_modules/tsx/dist/cli.mjs",
"src/stdio.ts",
]);
});
it("publishes a bin entry for npx-based stdio startup", async () => {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
expect(packageJson.name).toBeDefined();
expect(packageJson.bin).toEqual({
[packageJson.name as string]: "./dist/stdio.js",
});
});
it("does not write non-protocol logs to stdout from source entry", async () => {
const source = await readFile(stdioEntryPath, "utf8");
expect(source).not.toMatch(/console\.log\(/);
expect(source).not.toMatch(/process\.stdout\.write\(/);
});
it("keeps the published stdio entry executable", async () => {
const source = await readFile(stdioEntryPath, "utf8");
expect(source).toMatch(/^#!\/usr\/bin\/env node/m);
});
it("publishes a dedicated package-level verification script for the bin entry", async () => {
const packageJsonRaw = await readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonRaw) as StdioCommandConfig;
expect(packageJson.scripts?.["test:publish"]).toBe("node ./scripts/verify-publish-bin.mjs");
});
});