diff --git a/tests/mcp-tools.test.ts b/tests/mcp-tools.test.ts new file mode 100644 index 0000000..dfdd2f4 --- /dev/null +++ b/tests/mcp-tools.test.ts @@ -0,0 +1,388 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerConnectTool } from "../src/tools/connect.js"; +import { registerExecuteTool } from "../src/tools/execute.js"; +import { registerDisconnectTool } from "../src/tools/disconnect.js"; +import { registerListTool } from "../src/tools/list.js"; + +/** + * MCP tool registration tests. + * + * These tests verify that each SSH tool is properly registered with the MCP + * server, including correct tool names, titles, descriptions, and input schema + * fields. The tests access the server's internal _registeredTools map to + * inspect what was registered without needing a transport connection. + */ + +// --------------------------------------------------------------------------- +// Minimal test runner (matches project convention) +// --------------------------------------------------------------------------- + +interface TestResult { + name: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; +let currentDescribe = ""; + +function describe(name: string, fn: () => void) { + currentDescribe = name; + fn(); + currentDescribe = ""; +} + +function it(name: string, fn: () => void) { + const fullName = currentDescribe ? `${currentDescribe} > ${name}` : name; + try { + fn(); + results.push({ name: fullName, passed: true }); + } catch (err: any) { + results.push({ name: fullName, passed: false, error: err.message }); + } +} + +function expect(actual: T) { + return { + toBe(expected: T) { + if (actual !== expected) { + throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + }, + toEqual(expected: T) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` + ); + } + }, + toBeTruthy() { + if (!actual) { + throw new Error(`Expected truthy value, got ${JSON.stringify(actual)}`); + } + }, + toBeFalsy() { + if (actual) { + throw new Error(`Expected falsy value, got ${JSON.stringify(actual)}`); + } + }, + toBeUndefined() { + if (actual !== undefined) { + throw new Error(`Expected undefined, got ${JSON.stringify(actual)}`); + } + }, + toBeDefined() { + if (actual === undefined) { + throw new Error(`Expected defined value, got undefined`); + } + }, + toContain(item: any) { + if (!Array.isArray(actual) || !actual.includes(item)) { + throw new Error( + `Expected ${JSON.stringify(actual)} to contain ${JSON.stringify(item)}` + ); + } + }, + toBeGreaterThanOrEqual(expected: number) { + if (typeof actual !== "number" || actual < expected) { + throw new Error(`Expected ${actual} >= ${expected}`); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Helper: access the internal _registeredTools from the McpServer instance +// --------------------------------------------------------------------------- + +function getRegisteredTools(server: McpServer): Record { + return (server as any)._registeredTools; +} + +function getToolNames(server: McpServer): string[] { + return Object.keys(getRegisteredTools(server)); +} + +// --------------------------------------------------------------------------- +// Setup: create a server and register all tools +// --------------------------------------------------------------------------- + +const server = new McpServer({ + name: "ssh-mcp-server-test", + version: "1.0.0", +}); + +registerConnectTool(server); +registerExecuteTool(server); +registerDisconnectTool(server); +registerListTool(server); + +const tools = getRegisteredTools(server); + +// --------------------------------------------------------------------------- +// Tests: all four tools are registered +// --------------------------------------------------------------------------- + +describe("tool registration", () => { + it("registers exactly four tools", () => { + expect(getToolNames(server).length).toBe(4); + }); + + it("registers the ssh_connect tool", () => { + expect(tools["ssh_connect"]).toBeDefined(); + }); + + it("registers the ssh_execute tool", () => { + expect(tools["ssh_execute"]).toBeDefined(); + }); + + it("registers the ssh_disconnect tool", () => { + expect(tools["ssh_disconnect"]).toBeDefined(); + }); + + it("registers the ssh_list_connections tool", () => { + expect(tools["ssh_list_connections"]).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: ssh_connect tool metadata and schema +// --------------------------------------------------------------------------- + +describe("ssh_connect tool", () => { + const tool = tools["ssh_connect"]; + + it("has the correct title", () => { + expect(tool.title).toBe("SSH Connect"); + }); + + it("has a non-empty description", () => { + expect(typeof tool.description).toBe("string"); + expect((tool.description as string).length > 0).toBe(true); + }); + + it("has an inputSchema defined", () => { + expect(tool.inputSchema).toBeDefined(); + }); + + it("has a required 'host' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.host).toBeDefined(); + }); + + it("has optional 'username' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.username).toBeDefined(); + }); + + it("has optional 'port' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.port).toBeDefined(); + }); + + it("has optional 'password' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.password).toBeDefined(); + }); + + it("has optional 'privateKeyPath' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.privateKeyPath).toBeDefined(); + }); + + it("has optional 'passphrase' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.passphrase).toBeDefined(); + }); + + it("has optional 'useAgent' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.useAgent).toBeDefined(); + }); + + it("has optional 'connectTimeout' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.connectTimeout).toBeDefined(); + }); + + it("has optional 'connectionId' field in its input schema", () => { + const schema = tool.inputSchema; + expect(schema.connectionId).toBeDefined(); + }); + + it("has exactly 9 fields in its input schema", () => { + const fieldCount = Object.keys(tool.inputSchema).length; + expect(fieldCount).toBe(9); + }); + + it("is enabled by default", () => { + expect(tool.enabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: ssh_execute tool metadata and schema +// --------------------------------------------------------------------------- + +describe("ssh_execute tool", () => { + const tool = tools["ssh_execute"]; + + it("has the correct title", () => { + expect(tool.title).toBe("SSH Execute"); + }); + + it("has a non-empty description mentioning output cap", () => { + expect(typeof tool.description).toBe("string"); + expect((tool.description as string).includes("1MB")).toBe(true); + }); + + it("has a required 'connectionId' field in its input schema", () => { + expect(tool.inputSchema.connectionId).toBeDefined(); + }); + + it("has a required 'command' field in its input schema", () => { + expect(tool.inputSchema.command).toBeDefined(); + }); + + it("has optional 'cwd' field in its input schema", () => { + expect(tool.inputSchema.cwd).toBeDefined(); + }); + + it("has optional 'timeout' field in its input schema", () => { + expect(tool.inputSchema.timeout).toBeDefined(); + }); + + it("has optional 'errOnNonZero' field in its input schema", () => { + expect(tool.inputSchema.errOnNonZero).toBeDefined(); + }); + + it("has exactly 5 fields in its input schema", () => { + const fieldCount = Object.keys(tool.inputSchema).length; + expect(fieldCount).toBe(5); + }); + + it("is enabled by default", () => { + expect(tool.enabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: ssh_disconnect tool metadata and schema +// --------------------------------------------------------------------------- + +describe("ssh_disconnect tool", () => { + const tool = tools["ssh_disconnect"]; + + it("has the correct title", () => { + expect(tool.title).toBe("SSH Disconnect"); + }); + + it("has a non-empty description", () => { + expect(typeof tool.description).toBe("string"); + expect((tool.description as string).length > 0).toBe(true); + }); + + it("has a required 'connectionId' field in its input schema", () => { + expect(tool.inputSchema.connectionId).toBeDefined(); + }); + + it("has exactly 1 field in its input schema", () => { + const fieldCount = Object.keys(tool.inputSchema).length; + expect(fieldCount).toBe(1); + }); + + it("is enabled by default", () => { + expect(tool.enabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: ssh_list_connections tool metadata and schema +// --------------------------------------------------------------------------- + +describe("ssh_list_connections tool", () => { + const tool = tools["ssh_list_connections"]; + + it("has the correct title", () => { + expect(tool.title).toBe("SSH List Connections"); + }); + + it("has a non-empty description", () => { + expect(typeof tool.description).toBe("string"); + expect((tool.description as string).length > 0).toBe(true); + }); + + it("has an empty input schema (no parameters required)", () => { + const fieldCount = Object.keys(tool.inputSchema).length; + expect(fieldCount).toBe(0); + }); + + it("is enabled by default", () => { + expect(tool.enabled).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: duplicate registration prevention +// --------------------------------------------------------------------------- + +describe("duplicate registration prevention", () => { + it("throws when registering the same tool name twice", () => { + const freshServer = new McpServer({ + name: "duplicate-test", + version: "1.0.0", + }); + registerConnectTool(freshServer); + let threw = false; + try { + registerConnectTool(freshServer); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: tool names follow naming convention +// --------------------------------------------------------------------------- + +describe("tool naming convention", () => { + it("all tool names start with 'ssh_' prefix", () => { + const names = getToolNames(server); + for (const name of names) { + expect(name.startsWith("ssh_")).toBe(true); + } + }); + + it("tool names use snake_case", () => { + const names = getToolNames(server); + const snakeCasePattern = /^[a-z][a-z0-9_]*$/; + for (const name of names) { + expect(snakeCasePattern.test(name)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// Run and report +// --------------------------------------------------------------------------- + +const passed = results.filter((r) => r.passed).length; +const failed = results.filter((r) => !r.passed).length; + +console.log(`\nTest Results: ${passed} passed, ${failed} failed, ${results.length} total\n`); + +for (const r of results) { + const icon = r.passed ? "PASS" : "FAIL"; + console.log(` [${icon}] ${r.name}`); + if (!r.passed && r.error) { + console.log(` ${r.error}`); + } +} + +console.log(); + +if (failed > 0) { + process.exit(1); +}