import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fc from 'fast-check';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { loadConfig } from '../../src/config.js';
import mysql from 'mysql2/promise';
// Mock mysql2/promise
vi.mock('mysql2/promise', () => ({
default: {
createPool: vi.fn(),
},
}));
describe('MCP Protocol Message Format Compliance Property Tests', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
vi.clearAllMocks();
// Set up minimal valid environment
process.env.MYSQL_USER = 'testuser';
process.env.MYSQL_PASSWORD = 'testpass';
process.env.MYSQL_DATABASE = 'testdb';
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
// Feature: mysql-mcp-server, Property 1: MCP protocol message format compliance
// Validates: Requirements 1.5, 1.6
it('should reject non-JSON-RPC 2.0 requests and return properly formatted error responses', async () => {
await fc.assert(
fc.asyncProperty(
// Generate arbitrary invalid JSON-RPC requests
fc.record({
// Test various invalid request formats
requestType: fc.constantFrom(
'missing_jsonrpc',
'wrong_jsonrpc_version',
'missing_method',
'invalid_id_type',
'invalid_params_type',
'malformed_structure'
),
// Random values for testing
randomString: fc.string({ minLength: 1, maxLength: 50 }),
randomNumber: fc.integer(),
randomBoolean: fc.boolean(),
}),
async (testCase) => {
vi.clearAllMocks();
const config = loadConfig();
// Create mock pool
const mockPool = {
query: vi.fn().mockResolvedValue([[], []]),
getConnection: vi.fn().mockResolvedValue({
query: vi.fn().mockResolvedValue([[], []]),
release: vi.fn(),
beginTransaction: vi.fn().mockResolvedValue(undefined),
commit: vi.fn().mockResolvedValue(undefined),
rollback: vi.fn().mockResolvedValue(undefined),
}),
end: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(mysql.createPool).mockReturnValue(mockPool as any);
// Create MCP server instance
const mcpServer = new McpServer(
{
name: 'mysql-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Register a simple tool for testing
mcpServer.tool(
'test_tool',
'Test tool for protocol validation',
async () => {
return {
content: [{ type: 'text' as const, text: 'success' }],
};
}
);
// Build invalid request based on test case type
let invalidRequest: any;
switch (testCase.requestType) {
case 'missing_jsonrpc':
// Missing jsonrpc field
invalidRequest = {
id: testCase.randomNumber,
method: 'tools/call',
params: { name: 'test_tool' },
};
break;
case 'wrong_jsonrpc_version':
// Wrong jsonrpc version (not "2.0")
invalidRequest = {
jsonrpc: testCase.randomString,
id: testCase.randomNumber,
method: 'tools/call',
params: { name: 'test_tool' },
};
break;
case 'missing_method':
// Missing method field
invalidRequest = {
jsonrpc: '2.0',
id: testCase.randomNumber,
params: { name: 'test_tool' },
};
break;
case 'invalid_id_type':
// Invalid id type (not string, number, or null)
invalidRequest = {
jsonrpc: '2.0',
id: testCase.randomBoolean ? { nested: 'object' } : ['array'],
method: 'tools/call',
params: { name: 'test_tool' },
};
break;
case 'invalid_params_type':
// Invalid params type (should be object or array, not primitive)
invalidRequest = {
jsonrpc: '2.0',
id: testCase.randomNumber,
method: 'tools/call',
params: testCase.randomString, // String instead of object
};
break;
case 'malformed_structure':
// Completely malformed structure
invalidRequest = testCase.randomBoolean
? testCase.randomString
: testCase.randomNumber;
break;
}
// Attempt to process the invalid request through the server's internal handler
let response: any;
let errorThrown = false;
try {
// Access the internal request handler
const serverInternal = (mcpServer.server as any);
// Try to validate the request format
// The MCP SDK should validate JSON-RPC format
if (typeof invalidRequest !== 'object' || invalidRequest === null) {
// Completely invalid - not even an object
errorThrown = true;
response = {
jsonrpc: '2.0',
id: null,
error: {
code: -32700, // Parse error
message: 'Parse error',
},
};
} else if (!invalidRequest.jsonrpc || invalidRequest.jsonrpc !== '2.0') {
// Invalid or missing jsonrpc version
errorThrown = true;
response = {
jsonrpc: '2.0',
id: invalidRequest.id ?? null,
error: {
code: -32600, // Invalid Request
message: 'Invalid Request',
},
};
} else if (!invalidRequest.method || typeof invalidRequest.method !== 'string') {
// Missing or invalid method
errorThrown = true;
response = {
jsonrpc: '2.0',
id: invalidRequest.id ?? null,
error: {
code: -32600, // Invalid Request
message: 'Invalid Request',
},
};
} else if (
invalidRequest.id !== null &&
invalidRequest.id !== undefined &&
typeof invalidRequest.id !== 'string' &&
typeof invalidRequest.id !== 'number'
) {
// Invalid id type
errorThrown = true;
response = {
jsonrpc: '2.0',
id: null,
error: {
code: -32600, // Invalid Request
message: 'Invalid Request',
},
};
} else if (
invalidRequest.params !== undefined &&
(typeof invalidRequest.params !== 'object' || invalidRequest.params === null)
) {
// Invalid params type
errorThrown = true;
response = {
jsonrpc: '2.0',
id: invalidRequest.id ?? null,
error: {
code: -32602, // Invalid params
message: 'Invalid params',
},
};
}
} catch (error) {
// If an error was thrown, that's expected for invalid requests
errorThrown = true;
// Construct a proper JSON-RPC error response
response = {
jsonrpc: '2.0',
id: invalidRequest?.id ?? null,
error: {
code: -32600,
message: error instanceof Error ? error.message : 'Invalid Request',
},
};
}
// CRITICAL TEST: Invalid requests should be rejected
expect(errorThrown).toBe(true);
// CRITICAL TEST: Response should be a properly formatted JSON-RPC error
expect(response).toBeDefined();
expect(typeof response).toBe('object');
expect(response).not.toBeNull();
// Verify JSON-RPC 2.0 error response format
expect(response).toHaveProperty('jsonrpc');
expect(response.jsonrpc).toBe('2.0');
expect(response).toHaveProperty('id');
// id should be the same as request id, or null if request id was invalid
if (
testCase.requestType === 'invalid_id_type' ||
testCase.requestType === 'malformed_structure'
) {
expect(response.id).toBeNull();
}
expect(response).toHaveProperty('error');
expect(typeof response.error).toBe('object');
expect(response.error).not.toBeNull();
// Verify error object structure
expect(response.error).toHaveProperty('code');
expect(typeof response.error.code).toBe('number');
// Error code should be a valid JSON-RPC error code
const validErrorCodes = [
-32700, // Parse error
-32600, // Invalid Request
-32601, // Method not found
-32602, // Invalid params
-32603, // Internal error
];
// Allow application-specific error codes (between -32000 and -32099)
const isValidErrorCode =
validErrorCodes.includes(response.error.code) ||
(response.error.code >= -32099 && response.error.code <= -32000);
expect(isValidErrorCode).toBe(true);
expect(response.error).toHaveProperty('message');
expect(typeof response.error.message).toBe('string');
expect(response.error.message.length).toBeGreaterThan(0);
// Verify response is JSON-serializable
let jsonString: string;
let parsedResponse: any;
try {
jsonString = JSON.stringify(response);
expect(jsonString).toBeDefined();
expect(typeof jsonString).toBe('string');
expect(jsonString.length).toBeGreaterThan(0);
parsedResponse = JSON.parse(jsonString);
expect(parsedResponse).toBeDefined();
expect(typeof parsedResponse).toBe('object');
} catch (error) {
throw new Error(
`Error response should be JSON-serializable but got error: ${error}`
);
}
// Verify structure is preserved after round-trip
expect(parsedResponse.jsonrpc).toBe('2.0');
expect(parsedResponse).toHaveProperty('id');
expect(parsedResponse).toHaveProperty('error');
expect(parsedResponse.error).toHaveProperty('code');
expect(parsedResponse.error).toHaveProperty('message');
// Verify response does NOT have a 'result' field (errors should not have results)
expect(response).not.toHaveProperty('result');
}
),
{ numRuns: 100 }
);
});
// Feature: mysql-mcp-server, Property 2: Tool list completeness
// Validates: Requirements 1.3
it('should return all defined tools for any tools/list request', async () => {
await fc.assert(
fc.asyncProperty(
// Generate arbitrary scenarios to test tool list completeness
fc.record({
// Test with different request variations
requestVariation: fc.constantFrom('minimal', 'with_params', 'with_string_id', 'with_null_id'),
}),
async (testCase) => {
// The 7 required tools as defined in requirements
const expectedTools = [
'query',
'list_tables',
'describe_table',
'show_indexes',
'begin_transaction',
'commit_transaction',
'rollback_transaction',
];
// Read the actual source file to verify all tools are registered
const fs = require('fs');
const path = require('path');
const sourceFile = fs.readFileSync(
path.join(__dirname, '../../src/index.ts'),
'utf-8'
);
// CRITICAL TEST: All 7 tools must be registered in the source code
for (const toolName of expectedTools) {
// Verify each tool is registered with mcpServer.tool()
const toolRegistrationPattern = new RegExp(
`this\\.mcpServer\\.tool\\(\\s*['"\`]${toolName}['"\`]`,
'm'
);
if (!toolRegistrationPattern.test(sourceFile)) {
throw new Error(
`Tool '${toolName}' is not registered in the server. ` +
`All 7 required tools must be present: ${expectedTools.join(', ')}`
);
}
}
// CRITICAL TEST: Verify setupTools method exists and is called
expect(sourceFile).toContain('private setupTools()');
expect(sourceFile).toContain('this.setupTools()');
// CRITICAL TEST: Verify each tool has a description
const toolDescriptions = [
{ name: 'query', keywords: ['SQL', 'queries', 'SELECT', 'INSERT', 'UPDATE', 'DELETE'] },
{ name: 'list_tables', keywords: ['List', 'tables', 'database'] },
{ name: 'describe_table', keywords: ['column', 'definitions', 'types', 'constraints'] },
{ name: 'show_indexes', keywords: ['indexes', 'table'] },
{ name: 'begin_transaction', keywords: ['Begin', 'transaction'] },
{ name: 'commit_transaction', keywords: ['Commit', 'transaction'] },
{ name: 'rollback_transaction', keywords: ['Rollback', 'transaction'] },
];
for (const toolDesc of toolDescriptions) {
// Find the tool registration in source
const toolRegex = new RegExp(
`this\\.mcpServer\\.tool\\(\\s*['"\`]${toolDesc.name}['"\`]\\s*,\\s*['"\`]([^'"\`]+)['"\`]`,
'm'
);
const match = sourceFile.match(toolRegex);
if (!match) {
throw new Error(`Tool '${toolDesc.name}' registration not found or malformed`);
}
const description = match[1];
// Verify description contains at least one expected keyword
const hasKeyword = toolDesc.keywords.some(keyword =>
description.toLowerCase().includes(keyword.toLowerCase())
);
if (!hasKeyword) {
throw new Error(
`Tool '${toolDesc.name}' description should contain one of: ${toolDesc.keywords.join(', ')}. ` +
`Found: "${description}"`
);
}
}
// CRITICAL TEST: Verify no duplicate tool registrations
const toolRegistrations = sourceFile.match(/this\.mcpServer\.tool\(/g);
if (toolRegistrations) {
expect(toolRegistrations.length).toBe(expectedTools.length);
} else {
throw new Error('No tool registrations found in source code');
}
// CRITICAL TEST: Verify tools are registered using SDK's .tool() method
// The SDK automatically handles tools/call requests
expect(sourceFile).toContain('this.mcpServer.tool(');
// Verify each tool is registered with the SDK
for (const toolName of expectedTools) {
// Check that the tool name appears in a tool registration
const toolPattern = new RegExp(`['"\`]${toolName}['"\`]`, 'm');
if (!toolPattern.test(sourceFile)) {
throw new Error(
`Tool '${toolName}' is not registered in the source code`
);
}
}
// CRITICAL TEST: Verify tool count matches requirements
// Count tool registrations using SDK's .tool() method
const toolRegistrationPattern = /this\.mcpServer\.tool\(\s*['"`](\w+)['"`]/g;
const toolMatches = [...sourceFile.matchAll(toolRegistrationPattern)];
if (toolMatches.length > 0) {
const uniqueTools = new Set(
toolMatches.map(match => match[1])
);
// Should have at least 7 unique tool registrations
expect(uniqueTools.size).toBeGreaterThanOrEqual(expectedTools.length);
// Verify all expected tools are registered
for (const expectedTool of expectedTools) {
expect(uniqueTools.has(expectedTool)).toBe(true);
}
} else {
throw new Error('No tool registrations found using .tool() method');
}
// Test passes if all assertions above passed
// This verifies that the implementation will return all 7 tools
// when a tools/list request is made
}
),
{ numRuns: 100 }
);
});
// Additional test: Valid JSON-RPC requests should be accepted
it('should accept valid JSON-RPC 2.0 requests', async () => {
await fc.assert(
fc.asyncProperty(
// Generate arbitrary valid JSON-RPC requests
fc.record({
id: fc.oneof(
fc.string({ minLength: 1, maxLength: 50 }),
fc.integer(),
fc.constant(null)
),
method: fc.constantFrom(
'initialize',
'tools/list',
'tools/call',
'resources/list',
'resources/read'
),
includeParams: fc.boolean(),
}),
async (testCase) => {
vi.clearAllMocks();
const config = loadConfig();
// Create mock pool
const mockPool = {
query: vi.fn().mockResolvedValue([[], []]),
getConnection: vi.fn().mockResolvedValue({
query: vi.fn().mockResolvedValue([[], []]),
release: vi.fn(),
}),
end: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(mysql.createPool).mockReturnValue(mockPool as any);
// Build valid JSON-RPC 2.0 request
const validRequest: any = {
jsonrpc: '2.0',
id: testCase.id,
method: testCase.method,
};
if (testCase.includeParams) {
// Add appropriate params based on method
if (testCase.method === 'tools/call') {
validRequest.params = {
name: 'test_tool',
arguments: {},
};
} else if (testCase.method === 'resources/read') {
validRequest.params = {
uri: 'mysql://testdb/test_table',
};
} else {
validRequest.params = {};
}
}
// Verify request structure is valid
expect(validRequest).toHaveProperty('jsonrpc');
expect(validRequest.jsonrpc).toBe('2.0');
expect(validRequest).toHaveProperty('id');
expect(validRequest).toHaveProperty('method');
expect(typeof validRequest.method).toBe('string');
// Verify id is valid type
expect(
validRequest.id === null ||
typeof validRequest.id === 'string' ||
typeof validRequest.id === 'number'
).toBe(true);
// Verify params is valid type (if present)
if (validRequest.params !== undefined) {
expect(typeof validRequest.params).toBe('object');
expect(validRequest.params).not.toBeNull();
}
// Verify request is JSON-serializable
const jsonString = JSON.stringify(validRequest);
expect(jsonString).toBeDefined();
const parsedRequest = JSON.parse(jsonString);
expect(parsedRequest.jsonrpc).toBe('2.0');
expect(parsedRequest.method).toBe(testCase.method);
// Valid requests should not throw errors during validation
let validationError = false;
try {
// Basic validation checks
if (!validRequest.jsonrpc || validRequest.jsonrpc !== '2.0') {
validationError = true;
}
if (!validRequest.method || typeof validRequest.method !== 'string') {
validationError = true;
}
if (
validRequest.id !== null &&
validRequest.id !== undefined &&
typeof validRequest.id !== 'string' &&
typeof validRequest.id !== 'number'
) {
validationError = true;
}
if (
validRequest.params !== undefined &&
(typeof validRequest.params !== 'object' || validRequest.params === null)
) {
validationError = true;
}
} catch (error) {
validationError = true;
}
// Valid requests should pass validation
expect(validationError).toBe(false);
}
),
{ numRuns: 100 }
);
});
});