* feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) Bring the CLI and TUI in line with the web catalog vs read-only session model introduced in #1481/#1483, and close the outstanding --help item from #1347. Core (core/mcp/node/config.ts): - Add `catalogPath` to ServerConfigOptions (writable catalog slot). - `resolveServerSource()` picks the active source: `--catalog` (writable, seed-if-missing) wins over `--config` (read-only, error-if-missing). - Writable sources seed an empty `{"mcpServers":{}}` on first run (matches the web backend) instead of erroring; read-only `--config` still errors. - `serverSourceConflict()` shared conflict matrix (mirrors run-web): --catalog/--config mutually exclusive; neither combines with an ad-hoc target. - `readServerListFile()` public loader for runners (TUI) needing the raw map. - Rename `withDefaultConfigPath` → `withDefaultCatalogPath`; the no-flag default now fills the writable catalog slot, not --config. CLI / TUI: - Add `--catalog <path>` (+ MCP_CATALOG_PATH) and re-document `--config` as a read-only session in --help. - Enforce the shared conflict matrix before resolution. Full writable persistence/CRUD threading remains deferred to #1482 / #1432 (CLI/TUI are read consumers today; the split surfaces as seed- vs error-on-missing). Tests: core resolution matrix (source/conflict/seed/read-only), CLI catalog + conflict integration tests, TUI loadTuiServers unit tests. READMEs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(launcher): add smoke:cli + smoke:tui e2e launcher smokes (#1347) The launcher smokes only checked `--help` for --cli/--tui; nothing exercised the launcher → CLI/TUI → core path on the built artifact (only smoke:web did, for web). Add two end-to-end smokes wired into validate:launcher between smoke:launcher and smoke:web: - smoke:cli (scripts/smoke-cli.mjs): runs `--cli` against the bundled stdio test server via a temp --catalog and asserts tools/list returns the tools, plus the #1347 resolution paths — default-catalog seed-on-missing, read-only --config error-without-seed, and the --catalog/--config conflict. - smoke:tui (scripts/smoke-tui.mjs): launches `--tui --catalog <temp>` and asserts the Ink app renders its first frame ("MCP Servers") within a timeout, then SIGTERMs it (shallow boot/render check). Both build test-servers/build on demand if missing. Docs updated (AGENTS.md validate description, launcher README). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: address review on #1499 (comment accuracy, CLI/TUI flag parity) Review feedback from @claude on PR #1499 (all non-blocking): - Align CLI catalogPath resolution with the TUI: `options.catalog?.trim() || process.env.MCP_CATALOG_PATH` so an explicit empty `--catalog ""` falls back to MCP_CATALOG_PATH identically in both front-ends (CLI used `??`, which only guards undefined/null). - Clarify the `serverSourceConflict` comment: it mirrors only the source-selection portion of web's matrix and deliberately omits web's `--header` rejection, since CLI/TUI merge --header into per-server settings. - Note the deliberate --header broadcast semantics in loadTuiServers. - Share an EMPTY_CATALOG_CONTENT constant between seedEmptyCatalog and readServerListContent instead of two separate literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): trim --config to match --catalog and the TUI (#1499 review) Cosmetic uniformity follow-up from the re-review: configPath now uses `options.config?.trim() || undefined`, matching the catalog slot above it and the TUI. Behavior is unchanged (resolveServerSource/serverSourceConflict already trim downstream). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
926 lines
25 KiB
TypeScript
926 lines
25 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
import {
|
|
expectCliSuccess,
|
|
expectCliFailure,
|
|
expectValidJson,
|
|
} from "./helpers/assertions.js";
|
|
import {
|
|
NO_SERVER_SENTINEL,
|
|
createSampleTestConfig,
|
|
createTestConfig,
|
|
createInvalidConfig,
|
|
deleteConfigFile,
|
|
} from "./helpers/fixtures.js";
|
|
import {
|
|
getTestMcpServerCommand,
|
|
createTestServerHttp,
|
|
createEchoTool,
|
|
createTestServerInfo,
|
|
} from "@modelcontextprotocol/inspector-test-server";
|
|
|
|
describe("CLI Tests", () => {
|
|
describe("Basic CLI Mode", () => {
|
|
it("should execute tools/list successfully", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
|
|
// Validate expected tools from test-mcp-server
|
|
const toolNames = json.tools.map((tool: { name: string }) => tool.name);
|
|
expect(toolNames).toContain("echo");
|
|
expect(toolNames).toContain("get_sum");
|
|
expect(toolNames).toContain("get_annotated_message");
|
|
});
|
|
|
|
it("should fail with nonexistent method", async () => {
|
|
const result = await runCli([
|
|
NO_SERVER_SENTINEL,
|
|
"--cli",
|
|
"--method",
|
|
"nonexistent/method",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail without method", async () => {
|
|
const result = await runCli([NO_SERVER_SENTINEL, "--cli"]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Environment Variables", () => {
|
|
it("should accept environment variables", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"KEY1=value1",
|
|
"-e",
|
|
"KEY2=value2",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
|
|
// Parse the env vars from the resource
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.KEY1).toBe("value1");
|
|
expect(envVars.KEY2).toBe("value2");
|
|
});
|
|
|
|
it("should reject invalid environment variable format", async () => {
|
|
const result = await runCli([
|
|
NO_SERVER_SENTINEL,
|
|
"-e",
|
|
"INVALID_FORMAT",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should handle environment variable with equals sign in value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"API_KEY=abc123=xyz789==",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.API_KEY).toBe("abc123=xyz789==");
|
|
});
|
|
|
|
it("should handle environment variable with base64-encoded value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.JWT_TOKEN).toBe(
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Config File", () => {
|
|
it("should use config file with CLI mode", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when using config file without server name", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when using server name without config file", async () => {
|
|
const result = await runCli([
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail with nonexistent config file", async () => {
|
|
const result = await runCli([
|
|
"--config",
|
|
"./nonexistent-config.json",
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail with invalid config file format", async () => {
|
|
// Create invalid config temporarily
|
|
const invalidConfigPath = createInvalidConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
invalidConfigPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(invalidConfigPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with nonexistent server in config", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"nonexistent",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Catalog File", () => {
|
|
it("should use a writable catalog with CLI mode", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when --catalog and --config are combined", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--config",
|
|
catalogPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toMatch(/mutually exclusive/);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when --catalog is combined with an ad-hoc target", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
const { command, args } = getTestMcpServerCommand();
|
|
try {
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--catalog",
|
|
catalogPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toMatch(/--catalog cannot be combined/);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Resource Options", () => {
|
|
it("should read resource with URI", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"demo://resource/static/document/architecture.md",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
expect(json.contents[0]).toHaveProperty(
|
|
"uri",
|
|
"demo://resource/static/document/architecture.md",
|
|
);
|
|
expect(json.contents[0]).toHaveProperty("mimeType", "text/markdown");
|
|
expect(json.contents[0]).toHaveProperty("text");
|
|
expect(json.contents[0].text).toContain("Architecture Documentation");
|
|
});
|
|
|
|
it("should fail when reading resource without URI", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Prompt Options", () => {
|
|
it("should get prompt by name", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
"--prompt-name",
|
|
"simple_prompt",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("messages");
|
|
expect(Array.isArray(json.messages)).toBe(true);
|
|
expect(json.messages.length).toBeGreaterThan(0);
|
|
expect(json.messages[0]).toHaveProperty("role", "user");
|
|
expect(json.messages[0]).toHaveProperty("content");
|
|
expect(json.messages[0].content).toHaveProperty("type", "text");
|
|
expect(json.messages[0].content.text).toBe(
|
|
"This is a simple prompt for testing purposes.",
|
|
);
|
|
});
|
|
|
|
it("should get prompt with arguments", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
"--prompt-name",
|
|
"args_prompt",
|
|
"--prompt-args",
|
|
"city=New York",
|
|
"state=NY",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("messages");
|
|
expect(Array.isArray(json.messages)).toBe(true);
|
|
expect(json.messages.length).toBeGreaterThan(0);
|
|
expect(json.messages[0]).toHaveProperty("role", "user");
|
|
expect(json.messages[0]).toHaveProperty("content");
|
|
expect(json.messages[0].content).toHaveProperty("type", "text");
|
|
// Verify that the arguments were actually used in the response
|
|
expect(json.messages[0].content.text).toContain("city=New York");
|
|
expect(json.messages[0].content.text).toContain("state=NY");
|
|
});
|
|
|
|
it("should fail when getting prompt without name", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Logging Options", () => {
|
|
it("should set log level", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
logging: true,
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"logging/setLevel",
|
|
"--log-level",
|
|
"debug",
|
|
"--transport",
|
|
"http",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
// Validate the response - logging/setLevel should return an empty result
|
|
const json = expectValidJson(result);
|
|
expect(json).toEqual({});
|
|
|
|
// Validate that the server actually received and recorded the log level
|
|
expect(server.getCurrentLogLevel()).toBe("debug");
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should reject invalid log level", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"logging/setLevel",
|
|
"--log-level",
|
|
"invalid",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Combined Options", () => {
|
|
it("should handle config file with environment variables", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"-e",
|
|
"CLI_ENV_VAR=cli_value",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
|
|
// Parse the env vars from the resource
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars).toHaveProperty("CLI_ENV_VAR");
|
|
expect(envVars.CLI_ENV_VAR).toBe("cli_value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should handle all options together", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"-e",
|
|
"CLI_ENV_VAR=cli_value",
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-arg",
|
|
"message=Hello",
|
|
"--log-level",
|
|
"debug",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("content");
|
|
expect(Array.isArray(json.content)).toBe(true);
|
|
expect(json.content.length).toBeGreaterThan(0);
|
|
expect(json.content[0]).toHaveProperty("type", "text");
|
|
expect(json.content[0].text).toBe("Echo: Hello");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Config Transport Types", () => {
|
|
it("should work with stdio transport type", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-stdio": {
|
|
type: "stdio",
|
|
command,
|
|
args,
|
|
env: {
|
|
TEST_ENV: "test-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
// First validate tools/list works
|
|
const toolsResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(toolsResult);
|
|
const toolsJson = expectValidJson(toolsResult);
|
|
expect(toolsJson).toHaveProperty("tools");
|
|
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
|
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
|
|
|
// Then validate env vars from config are passed to server
|
|
const envResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(envResult);
|
|
const envJson = expectValidJson(envResult);
|
|
const envVars = JSON.parse(envJson.contents[0].text);
|
|
expect(envVars).toHaveProperty("TEST_ENV");
|
|
expect(envVars.TEST_ENV).toBe("test-value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with SSE transport type in CLI mode (connection error)", async () => {
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-sse": {
|
|
type: "sse",
|
|
url: "http://localhost:3000/sse",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with HTTP transport type in CLI mode (connection error)", async () => {
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-http": {
|
|
type: "streamable-http",
|
|
url: "http://localhost:3001/mcp",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should work with legacy config without type field", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-legacy": {
|
|
command,
|
|
args,
|
|
env: {
|
|
LEGACY_ENV: "legacy-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
// First validate tools/list works
|
|
const toolsResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-legacy",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(toolsResult);
|
|
const toolsJson = expectValidJson(toolsResult);
|
|
expect(toolsJson).toHaveProperty("tools");
|
|
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
|
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
|
|
|
// Then validate env vars from config are passed to server
|
|
const envResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-legacy",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(envResult);
|
|
const envJson = expectValidJson(envResult);
|
|
const envVars = JSON.parse(envJson.contents[0].text);
|
|
expect(envVars).toHaveProperty("LEGACY_ENV");
|
|
expect(envVars.LEGACY_ENV).toBe("legacy-value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Default Server Selection", () => {
|
|
it("should auto-select single server", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"only-server": {
|
|
command,
|
|
args,
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should require explicit server selection even with default-server key (multiple servers)", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"default-server": {
|
|
command,
|
|
args,
|
|
},
|
|
"other-server": {
|
|
command: "node",
|
|
args: ["other.js"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should require explicit server selection with multiple servers", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
server1: {
|
|
command,
|
|
args,
|
|
},
|
|
server2: {
|
|
command: "node",
|
|
args: ["other.js"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("HTTP Transport", () => {
|
|
it("should infer HTTP transport from URL ending with /mcp", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should work with explicit --transport http flag", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should work with explicit transport flag and URL suffix", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should fail when SSE transport is given to HTTP server", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should fail when HTTP transport is specified without URL", async () => {
|
|
const result = await runCli([
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail when SSE transport is specified without URL", async () => {
|
|
const result = await runCli([
|
|
"--transport",
|
|
"sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
});
|