/**
* mysql-mcp - McpServer Unit Tests
*
* Tests for MCP server lifecycle, adapter registration,
* tool filtering integration, and configuration.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { McpServer, createServer, parseMySQLConnectionString, DEFAULT_CONFIG } from '../McpServer.js';
import { createMockMySQLAdapter } from '../../__tests__/mocks/index.js';
// Mock the StdioServerTransport
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: class MockStdioTransport { }
}));
// Mock HTTP transport
const mockHttpTransport = {
start: vi.fn(),
stop: vi.fn()
};
vi.mock('../../transports/http.js', () => ({
createHttpTransport: vi.fn(() => mockHttpTransport)
}));
// Mock OAuth dependencies
vi.mock('../auth/OAuthResourceServer.js', () => ({
OAuthResourceServer: class MockOAuthResourceServer {
constructor(config: any) { }
}
}));
vi.mock('../auth/TokenValidator.js', () => ({
TokenValidator: class MockTokenValidator {
constructor(config: any) { }
}
}));
// Mock the MCP SDK server with a proper class - capture constructor args
let lastMockMcpServerOptions: unknown = null;
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: class MockMcpServer {
connect = vi.fn().mockResolvedValue(undefined);
close = vi.fn().mockResolvedValue(undefined);
tool = vi.fn();
resource = vi.fn();
prompt = vi.fn();
constructor(_serverInfo: unknown, options: unknown) {
lastMockMcpServerOptions = options;
}
}
}));
// Mock logger
vi.mock('../../utils/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
// Mock MCP logging
vi.mock('../../logging/McpLogging.js', () => ({
mcpLogger: {
setServer: vi.fn(),
setConnected: vi.fn(),
info: vi.fn(),
notice: vi.fn()
}
}));
// Mock progress reporter
vi.mock('../../progress/ProgressReporter.js', () => ({
progressFactory: {
setServer: vi.fn()
}
}));
describe('DEFAULT_CONFIG', () => {
it('should have correct default values', () => {
expect(DEFAULT_CONFIG.name).toBe('mysql-mcp');
expect(DEFAULT_CONFIG.version).toBe('0.1.0');
expect(DEFAULT_CONFIG.transport).toBe('stdio');
expect(DEFAULT_CONFIG.databases).toEqual([]);
});
});
describe('McpServer', () => {
let server: McpServer;
beforeEach(() => {
vi.clearAllMocks();
server = new McpServer();
});
describe('constructor', () => {
it('should create server with default config', () => {
const config = server.getConfig();
expect(config.name).toBe('mysql-mcp');
expect(config.version).toBe('0.1.0');
expect(config.transport).toBe('stdio');
});
it('should merge custom config with defaults', () => {
const customServer = new McpServer({
name: 'custom-server',
transport: 'http'
});
const config = customServer.getConfig();
expect(config.name).toBe('custom-server');
expect(config.transport).toBe('http');
expect(config.version).toBe('0.1.0'); // Default preserved
});
it('should parse tool filter from config', () => {
const filteredServer = new McpServer({
toolFilter: '-base,+starter'
});
const filter = filteredServer.getToolFilter();
expect(filter.rules.length).toBeGreaterThan(0);
});
it('should pass instructions to SDK server', () => {
new McpServer();
expect(lastMockMcpServerOptions).toHaveProperty('instructions');
expect((lastMockMcpServerOptions as { instructions: string }).instructions).toContain('mysql-mcp Usage Instructions');
});
});
describe('registerAdapter', () => {
it('should register an adapter', () => {
const mockAdapter = createMockMySQLAdapter();
server.registerAdapter(mockAdapter as unknown as Parameters<typeof server.registerAdapter>[0]);
expect(server.getAdapter('mysql:default')).toBe(mockAdapter);
});
it('should register adapter with custom alias', () => {
const mockAdapter = createMockMySQLAdapter();
server.registerAdapter(mockAdapter as unknown as Parameters<typeof server.registerAdapter>[0], 'primary-db');
expect(server.getAdapter('primary-db')).toBe(mockAdapter);
});
it('should not register duplicate adapters', () => {
const mockAdapter = createMockMySQLAdapter();
server.registerAdapter(mockAdapter as unknown as Parameters<typeof server.registerAdapter>[0]);
server.registerAdapter(mockAdapter as unknown as Parameters<typeof server.registerAdapter>[0]); // Should warn but not fail
expect(server.getAdapters().size).toBe(1);
});
});
describe('getAdapter', () => {
it('should return undefined for non-existent adapter', () => {
expect(server.getAdapter('non-existent')).toBeUndefined();
});
});
describe('getAdapters', () => {
it('should return empty map initially', () => {
expect(server.getAdapters().size).toBe(0);
});
it('should return all registered adapters', () => {
server.registerAdapter(createMockMySQLAdapter() as unknown as Parameters<typeof server.registerAdapter>[0], 'db1');
server.registerAdapter(createMockMySQLAdapter() as unknown as Parameters<typeof server.registerAdapter>[0], 'db2');
expect(server.getAdapters().size).toBe(2);
});
});
describe('start', () => {
it('should start the server with stdio transport by default', async () => {
await server.start();
expect(server.isRunning()).toBe(true);
});
it('should start with http transport', async () => {
const httpServer = new McpServer({
transport: 'http',
port: 8080
});
await httpServer.start();
expect(httpServer.isRunning()).toBe(true);
// Verify http transport was created and started
const { createHttpTransport } = await import('../../transports/http.js');
expect(createHttpTransport).toHaveBeenCalledWith(
expect.objectContaining({ port: 8080 }),
expect.any(Function)
);
expect(mockHttpTransport.start).toHaveBeenCalled();
});
it('should configure OAuth when enabled', async () => {
const oauthServer = new McpServer({
transport: 'http',
oauth: {
enabled: true,
issuer: 'https://auth.example.com',
audience: 'test-audience',
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
}
});
await oauthServer.start();
const { createHttpTransport } = await import('../../transports/http.js');
expect(createHttpTransport).toHaveBeenCalledWith(
expect.objectContaining({
resourceServer: expect.any(Object),
tokenValidator: expect.any(Object)
}),
expect.any(Function)
);
});
it('should throw if OAuth enabled but missing config', async () => {
const badConfigServer = new McpServer({
transport: 'http',
oauth: {
enabled: true,
// Missing issuer/audience
} as any
});
await expect(badConfigServer.start()).rejects.toThrow();
});
it('should fail if transport start fails', async () => {
mockHttpTransport.start.mockRejectedValueOnce(new Error('Port in use'));
const httpServer = new McpServer({ transport: 'http' });
await expect(httpServer.start()).rejects.toThrow('Port in use');
expect(httpServer.isRunning()).toBe(false);
});
it('should not start twice', async () => {
await server.start();
await server.start(); // Should warn but not fail
expect(server.isRunning()).toBe(true);
});
});
describe('stop', () => {
it('should stop the server', async () => {
await server.start();
expect(server.isRunning()).toBe(true);
await server.stop();
expect(server.isRunning()).toBe(false);
});
it('should stop active transport', async () => {
const httpServer = new McpServer({ transport: 'http' });
await httpServer.start();
await httpServer.stop();
expect(mockHttpTransport.stop).toHaveBeenCalled();
});
it('should safely handle transport stop errors', async () => {
const httpServer = new McpServer({ transport: 'http' });
await httpServer.start();
mockHttpTransport.stop.mockRejectedValueOnce(new Error('Stop failed'));
await httpServer.stop(); // Should not throw
expect(httpServer.isRunning()).toBe(false);
// cleanup for other tests
mockHttpTransport.stop.mockResolvedValue(undefined);
});
it('should do nothing if not started', async () => {
await server.stop(); // Should not throw
expect(server.isRunning()).toBe(false);
});
it('should disconnect all adapters', async () => {
const mockAdapter = createMockMySQLAdapter();
server.registerAdapter(mockAdapter as unknown as Parameters<typeof server.registerAdapter>[0]);
await server.start();
await server.stop();
expect(mockAdapter.disconnect).toHaveBeenCalled();
});
});
describe('getConfig', () => {
it('should return a copy of config', () => {
const config1 = server.getConfig();
const config2 = server.getConfig();
expect(config1).not.toBe(config2);
expect(config1).toEqual(config2);
});
});
describe('getToolFilter', () => {
it('should return tool filter config', () => {
const filter = server.getToolFilter();
expect(filter).toHaveProperty('enabledTools');
expect(filter).toHaveProperty('rules');
});
});
describe('isRunning', () => {
it('should return false initially', () => {
expect(server.isRunning()).toBe(false);
});
});
describe('getSdkServer', () => {
it('should return the SDK server instance', () => {
const sdk = server.getSdkServer();
expect(sdk).toBeDefined();
});
});
});
describe('createServer', () => {
it('should create a server instance', () => {
const server = createServer();
expect(server).toBeInstanceOf(McpServer);
});
it('should accept custom config', () => {
const server = createServer({ name: 'test-server' });
expect(server.getConfig().name).toBe('test-server');
});
});
describe('parseMySQLConnectionString', () => {
it('should parse basic connection string', () => {
const config = parseMySQLConnectionString('mysql://user:password@localhost:3306/testdb');
expect(config.type).toBe('mysql');
expect(config.host).toBe('localhost');
expect(config.port).toBe(3306);
expect(config.username).toBe('user');
expect(config.password).toBe('password');
expect(config.database).toBe('testdb');
});
it('should handle default port', () => {
const config = parseMySQLConnectionString('mysql://user:password@localhost/testdb');
expect(config.port).toBe(3306);
});
it('should decode URL-encoded credentials', () => {
const config = parseMySQLConnectionString('mysql://user%40domain:p%40ss%2Fword@localhost/db');
expect(config.username).toBe('user@domain');
expect(config.password).toBe('p@ss/word');
});
it('should parse query parameters as options', () => {
const config = parseMySQLConnectionString('mysql://user:pass@localhost/db?ssl=true&timeout=5000');
expect(config.options).toHaveProperty('ssl', 'true');
expect(config.options).toHaveProperty('timeout', '5000');
});
it('should handle different hosts', () => {
const config1 = parseMySQLConnectionString('mysql://u:p@host.docker.internal:3306/db');
expect(config1.host).toBe('host.docker.internal');
const config2 = parseMySQLConnectionString('mysql://u:p@192.168.1.100:3306/db');
expect(config2.host).toBe('192.168.1.100');
const config3 = parseMySQLConnectionString('mysql://u:p@db.example.com:3306/db');
expect(config3.host).toBe('db.example.com');
});
});