Skip to main content
Glama
MCPCommandTests.swift14.9 kB
import Commander import Darwin import Foundation import MCP import PeekabooCore import TachikomaMCP import Testing @testable import PeekabooCLI @Suite("MCP Command Tests") struct MCPCommandTests { // MARK: - Command Structure Tests @Test("MCP command has correct subcommands") func mCPCommandSubcommands() throws { let command = MCPCommand.self #expect(command.commandDescription.commandName == "mcp") #expect(command.commandDescription.subcommands.count == 10) var subcommandNames: [String] = [] subcommandNames.reserveCapacity(command.commandDescription.subcommands.count) for descriptor in command.commandDescription.subcommands { guard let name = descriptor.commandDescription.commandName else { continue } subcommandNames.append(name) } #expect(subcommandNames.contains("serve")) #expect(subcommandNames.contains("call")) #expect(subcommandNames.contains("list")) #expect(subcommandNames.contains("inspect")) #expect(subcommandNames.contains("add")) #expect(subcommandNames.contains("remove")) #expect(subcommandNames.contains("test")) #expect(subcommandNames.contains("info")) #expect(subcommandNames.contains("enable")) #expect(subcommandNames.contains("disable")) } @Test("MCP serve command default options") func mCPServeDefaults() throws { let serve = try MCPCommand.Serve.parse([]) #expect(serve.transport == "stdio") #expect(serve.port == 8080) } @Test("MCP serve command custom options") func mCPServeCustomOptions() throws { let serve = try MCPCommand.Serve.parse(["--transport", "http", "--port", "9000"]) #expect(serve.transport == "http") #expect(serve.port == 9000) } // MARK: - Help Text Tests @Test("MCP command help text") func mCPCommandHelp() { let helpText = MCPCommand.helpMessage() #expect(helpText.contains("Model Context Protocol server and client operations")) #expect(helpText.contains("serve")) #expect(helpText.contains("call")) #expect(helpText.contains("list")) } @Test("MCP serve help text") func mCPServeHelp() { let helpText = MCPCommand.Serve.helpMessage() #expect(helpText.contains("Start Peekaboo as an MCP server")) #expect(helpText.contains("claude mcp add peekaboo")) #expect(helpText.contains("npx @modelcontextprotocol/inspector")) #expect(helpText.contains("--transport")) #expect(helpText.contains("--port")) } // MARK: - Argument Parsing Tests @Test("Parse serve command with all transports") func parseServeAllTransports() throws { let transports = ["stdio", "http", "sse"] for transport in transports { let serve = try MCPCommand.Serve.parse(["--transport", transport]) #expect(serve.transport == transport) } } @Test("Parse inspect command") func parseInspectCommand() throws { let inspect1 = try MCPCommand.Inspect.parse(["my-server"]) #expect(inspect1.server == "my-server") let inspect2 = try MCPCommand.Inspect.parse([]) #expect(inspect2.server == nil) } // MARK: - Validation Tests @Test("Invalid port number throws error") func invalidPortNumber() throws { #expect(throws: (any Error).self) { try CLIOutputCapture.suppressStderr { _ = try MCPCommand.Serve.parse(["--port=-1"]) } } } @Test("Call command requires tool argument") func callCommandRequiresToolArgument() throws { #expect(throws: (any Error).self) { try CLIOutputCapture.suppressStderr { _ = try MCPCommand.Call.parse(["test-server"]) } } } } @Suite("MCP Command Integration Tests", .tags(.integration)) struct MCPCommandIntegrationTests { @Test("Serve command transport type conversion") func serveCommandTransportConversion() async throws { let serve = try MCPCommand.Serve.parse(["--transport", "stdio"]) // This test would need to actually run the serve command // and verify it starts the server with the correct transport // Since we can't easily test the actual server startup in unit tests, // we can at least verify the transport string maps correctly let expectedTransport: PeekabooCore.TransportType = .stdio #expect(serve.transport == expectedTransport.description) } @Test("Call command JSON parsing") func callCommandJSONParsing() throws { let validJSON = """ { "path": "/tmp/test.png", "format": "png", "nested": { "value": 123 } } """ let call = try MCPCommand.Call.parse([ "server", "test", "--args", validJSON ]) // Verify the JSON is stored correctly #expect(call.args == validJSON) // In the actual implementation, this JSON would be parsed // We can verify it's valid JSON let data = Data(call.args.utf8) #expect(throws: Never.self) { _ = try JSONSerialization.jsonObject(with: data) } } } private let defaultMCPServerName = "chrome-devtools" @Suite("MCP Command Error Handling Tests") struct MCPCommandErrorHandlingTests { @Test("Inspect command runs without throwing") func inspectCommandRuns() async throws { var inspect = try CLIOutputCapture.suppressStderr { try MCPCommand.Inspect.parse([]) } try await inspect.run() } } @Suite("MCP Call Command Runtime Tests", .tags(.fast)) @MainActor struct MCPCallCommandRuntimeTests { @Test("Mock manager echoes text content") func mockManagerReturnsText() async throws { let manager = MockMCPClientManager() manager.setServer(name: defaultMCPServerName) manager.executeResponse = .text("pong") let response = try await manager.execute( server: defaultMCPServerName, tool: "echo", args: [:] ) guard let first = response.content.first else { Issue.record("Expected text response from mock executeTool call.") return } if case let .text(value) = first { #expect(value == "pong") } else { Issue.record("Expected .text content in mock executeTool call.") } } @Test("Mock manager surfaces error responses") func mockManagerSurfacesErrors() async throws { let manager = MockMCPClientManager() manager.setServer(name: defaultMCPServerName) manager.executeResponse = .error("boom") let response = try await manager.execute( server: defaultMCPServerName, tool: "unstable", args: [:] ) guard let first = response.content.first else { Issue.record("Expected error ToolResponse from mock executeTool call.") return } #expect(response.isError == true) if case let .text(message) = first { #expect(message == "boom") } else { Issue.record("Expected .text content in error ToolResponse.") } } } @MainActor final class MockMCPClientManager: MCPClientService { private(set) var registeredDefaults: [String: TachikomaMCP.MCPServerConfig] = [:] private(set) var initializeCallCount = 0 private(set) var storedServers: [String: TachikomaMCP.MCPServerConfig] = [:] private(set) var connectedServers: Set<String> = [] var probeResult = ServerProbeResult(isConnected: true, toolCount: 0, responseTime: 0.01, error: nil) var executeResponse: ToolResponse = .text("ok") var executeError: (any Error)? private(set) var executeCallCount = 0 private(set) var probeCallCount = 0 func bootstrap(connect: Bool) async { self.initializeCallCount += 1 if connect { self.connectedServers.formUnion(self.storedServers.keys) } } func serverNames() -> [String] { Array(self.storedServers.keys) } func serverInfo(name: String) async -> PeekabooCLI.MCPServerInfo? { guard let config = self.storedServers[name] else { return nil } return PeekabooCLI.MCPServerInfo(name: name, config: config, connected: self.connectedServers.contains(name)) } func probeAll(timeoutMs _: Int) async -> [String: MCPServerHealth] { self.probeCallCount += 1 var results: [String: MCPServerHealth] = [:] for server in self.storedServers.keys { results[server] = await self.probe(name: server, timeoutMs: 0) } return results } func probe(name: String, timeoutMs _: Int) async -> MCPServerHealth { self.probeCallCount += 1 return self.probeResult.isConnected ? .connected(toolCount: self.probeResult.toolCount, responseTime: self.probeResult.responseTime) : .disconnected(error: self.probeResult.error ?? "unknown") } func execute(server: String, tool: String, args: [String: Any]) async throws -> ToolResponse { try await self.execute(serverName: server, toolName: tool, arguments: args) } func execute(serverName: String, toolName _: String, arguments _: [String: Any]) async throws -> ToolResponse { self.executeCallCount += 1 if let executeError { throw executeError } guard self.connectedServers.contains(serverName) else { throw ValidationError("Server \(serverName) not connected") } return self.executeResponse } func addServer(name: String, config: TachikomaMCP.MCPServerConfig) async throws { self.storedServers[name] = config } func removeServer(name: String) async { self.storedServers.removeValue(forKey: name) self.connectedServers.remove(name) } func enableServer(name: String) async throws { guard self.storedServers[name] != nil else { throw ValidationError("Server not found") } self.connectedServers.insert(name) } func disableServer(name: String) async { self.connectedServers.remove(name) } func persist() throws { // no-op } func checkServerHealth(name: String, timeoutMs: Int) async -> MCPServerHealth { await self.probe(name: name, timeoutMs: timeoutMs) } func externalToolsByServer() async -> [String: [MCP.Tool]] { [:] } // Helpers for tests func registerDefaultServers(_ defaults: [String: TachikomaMCP.MCPServerConfig]) { self.registeredDefaults = defaults } func setServer(name: String, enabled: Bool = true) { let config = TachikomaMCP.MCPServerConfig( transport: "stdio", command: "mock", args: [], env: [:], enabled: enabled, timeout: 5, autoReconnect: true, description: "mock server" ) self.storedServers[name] = config if enabled { self.connectedServers.insert(name) } } } // MARK: - Mock Tests for Server Behavior @Suite("MCP Server Behavior Tests") struct MCPServerBehaviorTests { @Test("Server exits cleanly on SIGTERM") func serverSIGTERMHandling() async throws { // This would test that the server handles SIGTERM gracefully // In practice, this requires spawning a subprocess and sending signals // For unit testing, we can at least verify the serve command structure let serve = try CLIOutputCapture.suppressStderr { try MCPCommand.Serve.parse([]) } #expect(serve.transport == "stdio") // Default value } @Test("Server validates transport types") func serverTransportValidation() async throws { var serve = try CLIOutputCapture.suppressStderr { try MCPCommand.Serve.parse([]) } // Test that invalid transport types are handled serve.transport = "invalid" // When run() is called, it should default to stdio for invalid types // This behavior is implemented in the run() method } } @Suite("MCP Command End-to-End Tests", .serialized, .tags(.integration)) @MainActor struct MCPCommandEndToEndTests { @Test("Add/list/test with stub MCP server") func addListAndTestStubServer() async throws { guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] == "true" else { return } let harness: MCPStubTestHarness do { harness = try MCPStubTestHarness() } catch MCPStubFixtures.FixtureError.missing { return } do { try await harness.addStubServer() let listResult = try await harness.run(["mcp", "list"]) #expect(listResult.stdout.contains(harness.serverName)) let testResult = try await harness.run([ "mcp", "test", harness.serverName, "--timeout", "5", "--show-tools", ]) #expect(testResult.stdout.contains("echo")) #expect(testResult.stdout.contains("add")) await harness.cleanup() } catch { await harness.cleanup() throw error } } @Test("Call stub MCP tools for success and failure") func callStubTools() async throws { guard ProcessInfo.processInfo.environment["RUN_LOCAL_TESTS"] == "true" else { return } let harness: MCPStubTestHarness do { harness = try MCPStubTestHarness() } catch MCPStubFixtures.FixtureError.missing { return } do { try await harness.addStubServer() let success = try await harness.run([ "mcp", "call", harness.serverName, "--tool", "echo", "--args", "{\"message\":\"hello world\"}", ]) #expect(success.stdout.contains("hello world")) let sum = try await harness.run([ "mcp", "call", harness.serverName, "--tool", "add", "--args", "{\"a\":2,\"b\":3}", ]) #expect(sum.stdout.contains("sum: 5")) let failure = try await harness.run([ "mcp", "call", harness.serverName, "--tool", "fail", "--args", "{\"message\":\"boom\"}", ], allowedExitCodes: Set<Int32>([0, 1])) #expect(failure.stdout.contains("boom")) await harness.cleanup() } catch { await harness.cleanup() throw error } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/steipete/Peekaboo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server