mcpb/test/cli.test.ts
Bryan Thompson a2c331b513
Merge pull request #208 from modelcontextprotocol/feat/pack-manifest-flag
feat: add --manifest flag to pack command
2026-04-14 07:42:57 -05:00

516 lines
18 KiB
TypeScript

import { execSync } from "node:child_process";
import fs from "node:fs";
import { join } from "node:path";
import { DEFAULT_MANIFEST_VERSION } from "../src/shared/constants";
interface ExecSyncError extends Error {
stdout: Buffer;
stderr: Buffer;
status: number | null;
signal: string | null;
}
describe("DXT CLI", () => {
const cliPath = join(__dirname, "../dist/cli/cli.js");
const validManifestPath = join(__dirname, "valid-manifest.json");
const invalidManifestPath = join(__dirname, "invalid-manifest.json");
beforeAll(() => {
// Ensure the CLI is built
execSync("yarn build", { cwd: join(__dirname, "..") });
});
it("should validate a valid manifest", () => {
const result = execSync(`node ${cliPath} validate ${validManifestPath}`, {
encoding: "utf-8",
});
expect(result).toContain("Manifest schema validation passes!");
});
it("should reject an invalid manifest", () => {
expect(() => {
execSync(`node ${cliPath} validate ${invalidManifestPath}`, {
encoding: "utf-8",
});
}).toThrow();
});
it("should show usage when no arguments provided", () => {
try {
execSync(`node ${cliPath}`, { encoding: "utf-8", stdio: "pipe" });
} catch (error) {
// Commander outputs help to stderr when no command is provided
const execError = error as ExecSyncError;
expect(execError.stderr.toString()).toContain(
"Usage: mcpb [options] [command]",
);
}
});
it("should handle non-existent files", () => {
try {
execSync(`node ${cliPath} validate /non/existent/file.json`, {
encoding: "utf-8",
stdio: "pipe",
});
fail("Should have thrown an error");
} catch (error) {
const execError = error as ExecSyncError;
expect(execError.stderr.toString()).toContain("ERROR: File not found");
}
});
it("should handle invalid JSON", () => {
const invalidJsonPath = join(__dirname, "invalid-json.json");
fs.writeFileSync(invalidJsonPath, "{ invalid json }");
try {
execSync(`node ${cliPath} validate ${invalidJsonPath}`, {
encoding: "utf-8",
stdio: "pipe",
});
fail("Should have thrown an error");
} catch (error) {
const execError = error as ExecSyncError;
expect(execError.stderr.toString()).toContain("ERROR: Invalid JSON");
}
// Clean up
fs.unlinkSync(invalidJsonPath);
});
describe("icon validation integration", () => {
const testDir = join(__dirname, "icon-test");
beforeAll(() => {
// Create test directory
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
// Create a valid PNG file (1x1 transparent pixel)
const validPngBuffer = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
fs.writeFileSync(join(testDir, "icon.png"), validPngBuffer);
// Create entry point file referenced by test manifests
fs.mkdirSync(join(testDir, "server"), { recursive: true });
fs.writeFileSync(join(testDir, "server", "index.js"), "// fixture");
});
afterAll(() => {
// Clean up test directory
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
it("should pass validation with valid local icon", () => {
const manifestWithIcon = join(testDir, "manifest-with-icon.json");
fs.writeFileSync(
manifestWithIcon,
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "test-with-icon",
version: "1.0.0",
description: "Test with icon",
author: { name: "Test" },
icon: "icon.png",
server: {
type: "node",
entry_point: "server/index.js",
mcp_config: { command: "node" },
},
}),
);
const result = execSync(`node ${cliPath} validate ${manifestWithIcon}`, {
encoding: "utf-8",
});
expect(result).toContain("Manifest schema validation passes!");
expect(result).toContain("Icon validation");
});
it("should warn about manifest with remote icon URL but not fail", () => {
const manifestWithUrl = join(testDir, "manifest-with-url.json");
fs.writeFileSync(
manifestWithUrl,
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "test-with-url",
version: "1.0.0",
description: "Test with URL",
author: { name: "Test" },
icon: "https://example.com/icon.png",
server: {
type: "node",
entry_point: "server/index.js",
mcp_config: { command: "node" },
},
}),
);
const result = execSync(`node ${cliPath} validate ${manifestWithUrl}`, {
encoding: "utf-8",
});
expect(result).toContain("Manifest schema validation passes!");
expect(result).toContain("Icon validation warnings");
expect(result).toContain("Icon path uses a remote URL");
});
});
describe("pack and unpack", () => {
const tempDir = join(__dirname, "temp-pack-test");
const packedFilePath = join(__dirname, "test-extension.dxt");
const unpackedDir = join(__dirname, "temp-unpack-test");
beforeAll(() => {
// Ensure the CLI is built
execSync("yarn build", { cwd: join(__dirname, "..") });
// Create a temporary directory with some files
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(
join(tempDir, "manifest.json"),
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "Test Extension",
version: "1.0.0",
description: "A test extension",
author: {
name: "MCPB",
},
server: {
type: "node",
entry_point: "server/index.js",
mcp_config: {
command: "node",
},
},
}),
);
fs.writeFileSync(join(tempDir, "file1.txt"), "hello");
fs.mkdirSync(join(tempDir, "subdir"));
fs.writeFileSync(join(tempDir, "subdir", "file2.txt"), "world");
// Create entry point file referenced by manifest
fs.mkdirSync(join(tempDir, "server"), { recursive: true });
fs.writeFileSync(join(tempDir, "server", "index.js"), "// fixture");
});
afterAll(() => {
// Clean up temporary files and directories
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(unpackedDir, { recursive: true, force: true });
if (fs.existsSync(packedFilePath)) {
fs.unlinkSync(packedFilePath);
}
});
it("should pack an extension", () => {
execSync(`node ${cliPath} pack ${tempDir} ${packedFilePath}`, {
encoding: "utf-8",
});
expect(fs.existsSync(packedFilePath)).toBe(true);
});
it("should unpack an extension", () => {
execSync(`node ${cliPath} unpack ${packedFilePath} ${unpackedDir}`, {
encoding: "utf-8",
});
expect(fs.existsSync(unpackedDir)).toBe(true);
expect(fs.existsSync(join(unpackedDir, "manifest.json"))).toBe(true);
expect(fs.existsSync(join(unpackedDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(join(unpackedDir, "subdir", "file2.txt"))).toBe(
true,
);
});
it("should have the same content after packing and unpacking", () => {
const originalManifest = fs.readFileSync(
join(tempDir, "manifest.json"),
"utf-8",
);
const unpackedManifest = fs.readFileSync(
join(unpackedDir, "manifest.json"),
"utf-8",
);
expect(originalManifest).toEqual(unpackedManifest);
const originalFile1 = fs.readFileSync(
join(tempDir, "file1.txt"),
"utf-8",
);
const unpackedFile1 = fs.readFileSync(
join(unpackedDir, "file1.txt"),
"utf-8",
);
expect(originalFile1).toEqual(unpackedFile1);
const originalFile2 = fs.readFileSync(
join(tempDir, "subdir", "file2.txt"),
"utf-8",
);
const unpackedFile2 = fs.readFileSync(
join(unpackedDir, "subdir", "file2.txt"),
"utf-8",
);
expect(originalFile2).toEqual(unpackedFile2);
});
it("should pack with --manifest pointing to a separate directory", () => {
const projectDir = join(__dirname, "temp-manifest-project");
const manifestDir = join(__dirname, "temp-manifest-separate");
const manifestPackedPath = join(__dirname, "test-manifest-flag.mcpb");
try {
// Create project directory with source files (no manifest)
fs.mkdirSync(join(projectDir, "server"), { recursive: true });
fs.writeFileSync(
join(projectDir, "server", "index.js"),
"console.log('hello');",
);
// Create separate manifest directory
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(
join(manifestDir, "manifest.json"),
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "Test Separate Manifest",
version: "1.0.0",
description: "Test with separate manifest",
author: { name: "MCPB" },
server: {
type: "node",
entry_point: "server/index.js",
mcp_config: { command: "node" },
},
}),
);
const result = execSync(
`node ${cliPath} pack ${projectDir} ${manifestPackedPath} --manifest ${join(manifestDir, "manifest.json")}`,
{ encoding: "utf-8" },
);
expect(fs.existsSync(manifestPackedPath)).toBe(true);
expect(result).toContain("Validating manifest");
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
if (fs.existsSync(manifestPackedPath)) {
fs.unlinkSync(manifestPackedPath);
}
}
});
it("should fail with --manifest pointing to nonexistent file", () => {
const projectDir = join(__dirname, "temp-manifest-missing-project");
try {
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(join(projectDir, "index.js"), "console.log('hello');");
expect(() => {
execSync(
`node ${cliPath} pack ${projectDir} /tmp/out.mcpb --manifest /nonexistent/manifest.json`,
{ encoding: "utf-8", stdio: "pipe" },
);
}).toThrow();
try {
execSync(
`node ${cliPath} pack ${projectDir} /tmp/out.mcpb --manifest /nonexistent/manifest.json`,
{ encoding: "utf-8", stdio: "pipe" },
);
} catch (error: unknown) {
const execError = error as { stdout?: Buffer; stderr?: Buffer };
const output =
(execError.stdout?.toString() || "") +
(execError.stderr?.toString() || "");
expect(output).toContain("Manifest file not found");
}
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
}
});
it("should preserve executable file permissions after packing and unpacking", () => {
// Skip this test on Windows since it doesn't support Unix permissions
if (process.platform === "win32") {
return;
}
const tempExecDir = join(__dirname, "temp-exec-test");
const execPackedFilePath = join(__dirname, "test-exec-extension.dxt");
const execUnpackedDir = join(__dirname, "temp-exec-unpack-test");
try {
// Create a temporary directory with an executable file
fs.mkdirSync(tempExecDir, { recursive: true });
fs.writeFileSync(
join(tempExecDir, "manifest.json"),
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "Test Executable Extension",
version: "1.0.0",
description: "A test extension with executable files",
author: {
name: "MCPB",
},
server: {
type: "node",
entry_point: "server/index.js",
mcp_config: {
command: "node",
},
},
}),
);
// Create entry point file referenced by manifest
fs.mkdirSync(join(tempExecDir, "server"), { recursive: true });
fs.writeFileSync(join(tempExecDir, "server", "index.js"), "// fixture");
// Create an executable script
const executableScript = join(tempExecDir, "run-script.sh");
fs.writeFileSync(
executableScript,
"#!/bin/bash\necho 'Hello from executable'",
);
fs.chmodSync(executableScript, 0o755); // Make it executable
// Create a regular file for comparison
const regularFile = join(tempExecDir, "regular-file.txt");
fs.writeFileSync(regularFile, "regular content");
fs.chmodSync(regularFile, 0o644); // Regular file permissions
// Pack the extension
execSync(`node ${cliPath} pack ${tempExecDir} ${execPackedFilePath}`, {
encoding: "utf-8",
});
// Unpack the extension
execSync(
`node ${cliPath} unpack ${execPackedFilePath} ${execUnpackedDir}`,
{
encoding: "utf-8",
},
);
// Check that the executable file preserved its permissions
const originalStats = fs.statSync(executableScript);
const unpackedStats = fs.statSync(
join(execUnpackedDir, "run-script.sh"),
);
// Check that executable permissions are preserved (0o755)
expect(unpackedStats.mode & 0o777).toBe(0o755);
expect(originalStats.mode & 0o777).toBe(unpackedStats.mode & 0o777);
// Check that regular file permissions are preserved (0o644)
const originalRegularStats = fs.statSync(regularFile);
const unpackedRegularStats = fs.statSync(
join(execUnpackedDir, "regular-file.txt"),
);
expect(unpackedRegularStats.mode & 0o777).toBe(0o644);
expect(originalRegularStats.mode & 0o777).toBe(
unpackedRegularStats.mode & 0o777,
);
} finally {
// Clean up
fs.rmSync(tempExecDir, { recursive: true, force: true });
fs.rmSync(execUnpackedDir, { recursive: true, force: true });
if (fs.existsSync(execPackedFilePath)) {
fs.unlinkSync(execPackedFilePath);
}
}
});
it("should add group+other execute bits on binary entry points during pack", () => {
// Skip this test on Windows since it doesn't support Unix permissions
if (process.platform === "win32") {
return;
}
const tempBinDir = join(__dirname, "temp-binary-entry-test");
const binPackedFilePath = join(__dirname, "test-binary-entry.dxt");
const binUnpackedDir = join(__dirname, "temp-binary-entry-unpack");
try {
// Create a binary-type extension with owner-only execute (0o700)
// This passes validation (has some +x) but pack should add
// group+other execute bits for compatibility with CD extraction
fs.mkdirSync(join(tempBinDir, "server"), { recursive: true });
fs.writeFileSync(
join(tempBinDir, "manifest.json"),
JSON.stringify({
manifest_version: DEFAULT_MANIFEST_VERSION,
name: "Test Binary Extension",
version: "1.0.0",
description: "A test extension with binary entry point",
author: {
name: "MCPB",
},
server: {
type: "binary",
entry_point: "server/myserver",
mcp_config: {
command: "${__dirname}/server/myserver",
},
},
}),
);
// Create binary entry point with 700 (owner-only execute)
fs.writeFileSync(
join(tempBinDir, "server", "myserver"),
"#!/bin/sh\necho binary",
);
fs.chmodSync(join(tempBinDir, "server", "myserver"), 0o700);
// Create a regular file for comparison (should stay 644)
fs.writeFileSync(join(tempBinDir, "config.json"), "{}");
fs.chmodSync(join(tempBinDir, "config.json"), 0o644);
// Pack the extension
execSync(`node ${cliPath} pack ${tempBinDir} ${binPackedFilePath}`, {
encoding: "utf-8",
});
// Unpack to check stored permissions
execSync(
`node ${cliPath} unpack ${binPackedFilePath} ${binUnpackedDir}`,
{
encoding: "utf-8",
},
);
// Binary entry point should have full execute bits (700 | 111 = 711)
const entryStats = fs.statSync(
join(binUnpackedDir, "server", "myserver"),
);
expect(entryStats.mode & 0o777).toBe(0o711);
// Non-entry-point file should retain original permissions
const configStats = fs.statSync(join(binUnpackedDir, "config.json"));
expect(configStats.mode & 0o777).toBe(0o644);
} finally {
fs.rmSync(tempBinDir, { recursive: true, force: true });
fs.rmSync(binUnpackedDir, { recursive: true, force: true });
if (fs.existsSync(binPackedFilePath)) {
fs.unlinkSync(binPackedFilePath);
}
}
});
});
});