Files
sim/apps/sim/lib/mcp/utils.test.ts
Waleed b7f6bab282 feat(tests): added testing package, overhauled tests (#2586)
* feat(tests): added testing package, overhauled tests

* fix build
2025-12-25 16:06:47 -08:00

382 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
categorizeError,
createMcpToolId,
generateMcpServerId,
MCP_CLIENT_CONSTANTS,
MCP_CONSTANTS,
parseMcpToolId,
validateRequiredFields,
validateStringParam,
} from './utils'
describe('generateMcpServerId', () => {
const workspaceId = 'ws-test-123'
const url = 'https://my-mcp-server.com/mcp'
it.concurrent('produces deterministic IDs for the same input', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, url)
expect(id1).toBe(id2)
})
it.concurrent('normalizes trailing slashes', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}/`)
const id3 = generateMcpServerId(workspaceId, `${url}//`)
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('is case insensitive for URL', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, 'https://MY-MCP-SERVER.com/mcp')
const id3 = generateMcpServerId(workspaceId, 'HTTPS://My-Mcp-Server.COM/MCP')
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('ignores query parameters', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}?token=abc123`)
const id3 = generateMcpServerId(workspaceId, `${url}?foo=bar&baz=qux`)
expect(id1).toBe(id2)
expect(id1).toBe(id3)
})
it.concurrent('ignores fragments', () => {
const id1 = generateMcpServerId(workspaceId, url)
const id2 = generateMcpServerId(workspaceId, `${url}#section`)
expect(id1).toBe(id2)
})
it.concurrent('produces different IDs for different workspaces', () => {
const id1 = generateMcpServerId('ws-123', url)
const id2 = generateMcpServerId('ws-456', url)
expect(id1).not.toBe(id2)
})
it.concurrent('produces different IDs for different URLs', () => {
const id1 = generateMcpServerId(workspaceId, 'https://server1.com/mcp')
const id2 = generateMcpServerId(workspaceId, 'https://server2.com/mcp')
expect(id1).not.toBe(id2)
})
it.concurrent('produces IDs in the correct format', () => {
const id = generateMcpServerId(workspaceId, url)
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
})
it.concurrent('handles URLs with ports', () => {
const id1 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp')
const id2 = generateMcpServerId(workspaceId, 'https://localhost:3000/mcp/')
expect(id1).toBe(id2)
expect(id1).toMatch(/^mcp-[a-f0-9]{8}$/)
})
it.concurrent('handles invalid URLs gracefully', () => {
const id = generateMcpServerId(workspaceId, 'not-a-valid-url')
expect(id).toMatch(/^mcp-[a-f0-9]{8}$/)
})
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
expect(MCP_CONSTANTS.CACHE_TIMEOUT).toBe(5 * 60 * 1000)
})
it.concurrent('has correct default retries', () => {
expect(MCP_CONSTANTS.DEFAULT_RETRIES).toBe(3)
})
it.concurrent('has correct default connection timeout', () => {
expect(MCP_CONSTANTS.DEFAULT_CONNECTION_TIMEOUT).toBe(30000)
})
it.concurrent('has correct max cache size', () => {
expect(MCP_CONSTANTS.MAX_CACHE_SIZE).toBe(1000)
})
it.concurrent('has correct max consecutive failures', () => {
expect(MCP_CONSTANTS.MAX_CONSECUTIVE_FAILURES).toBe(3)
})
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.AUTO_REFRESH_INTERVAL).toBe(5 * 60 * 1000)
})
})
describe('validateStringParam', () => {
it.concurrent('returns valid for non-empty string', () => {
const result = validateStringParam('test-value', 'testParam')
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid for empty string', () => {
const result = validateStringParam('', 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for null', () => {
const result = validateStringParam(null, 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for undefined', () => {
const result = validateStringParam(undefined, 'testParam')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('testParam is required and must be a string')
}
})
it.concurrent('returns invalid for number', () => {
const result = validateStringParam(123, 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('returns invalid for object', () => {
const result = validateStringParam({ foo: 'bar' }, 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('returns invalid for array', () => {
const result = validateStringParam(['test'], 'testParam')
expect(result.isValid).toBe(false)
})
it.concurrent('includes param name in error message', () => {
const result = validateStringParam(null, 'customParamName')
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toContain('customParamName')
}
})
})
describe('validateRequiredFields', () => {
it.concurrent('returns valid when all required fields are present', () => {
const body = { field1: 'value1', field2: 'value2', field3: 'value3' }
const result = validateRequiredFields(body, ['field1', 'field2'])
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid when a required field is missing', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, ['field1', 'field2'])
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('Missing required fields: field2')
}
})
it.concurrent('returns invalid with multiple missing fields', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, ['field1', 'field2', 'field3'])
expect(result.isValid).toBe(false)
if (!result.isValid) {
expect(result.error).toBe('Missing required fields: field2, field3')
}
})
it.concurrent('returns valid with empty required fields array', () => {
const body = { field1: 'value1' }
const result = validateRequiredFields(body, [])
expect(result.isValid).toBe(true)
})
it.concurrent('returns invalid when body is empty and fields are required', () => {
const body = {}
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(false)
})
it.concurrent('considers null values as present', () => {
const body = { field1: null }
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(true)
})
it.concurrent('considers undefined values as present when key exists', () => {
const body = { field1: undefined }
const result = validateRequiredFields(body, ['field1'])
expect(result.isValid).toBe(true)
})
})
describe('categorizeError', () => {
it.concurrent('returns 408 for timeout errors', () => {
const error = new Error('Request timeout occurred')
const result = categorizeError(error)
expect(result.status).toBe(408)
expect(result.message).toBe('Request timed out')
})
it.concurrent('returns 408 for timeout in message (case insensitive)', () => {
const error = new Error('Operation TIMEOUT')
const result = categorizeError(error)
expect(result.status).toBe(408)
})
it.concurrent('returns 404 for not found errors', () => {
const error = new Error('Resource not found')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Resource not found')
})
it.concurrent('returns 404 for not accessible errors', () => {
const error = new Error('Server not accessible')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Server not accessible')
})
it.concurrent('returns 401 for authentication errors', () => {
const error = new Error('Authentication failed')
const result = categorizeError(error)
expect(result.status).toBe(401)
expect(result.message).toBe('Authentication required')
})
it.concurrent('returns 401 for unauthorized errors', () => {
const error = new Error('Unauthorized access attempt')
const result = categorizeError(error)
expect(result.status).toBe(401)
expect(result.message).toBe('Authentication required')
})
it.concurrent('returns 400 for invalid input errors', () => {
const error = new Error('Invalid parameter provided')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid parameter provided')
})
it.concurrent('returns 400 for missing required errors', () => {
const error = new Error('Missing required field: name')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Missing required field: name')
})
it.concurrent('returns 400 for validation errors', () => {
const error = new Error('Validation failed for input')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Validation failed for input')
})
it.concurrent('returns 500 for generic errors', () => {
const error = new Error('Something went wrong')
const result = categorizeError(error)
expect(result.status).toBe(500)
expect(result.message).toBe('Something went wrong')
})
it.concurrent('returns 500 for non-Error objects', () => {
const result = categorizeError('string error')
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 500 for null', () => {
const result = categorizeError(null)
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 500 for undefined', () => {
const result = categorizeError(undefined)
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
it.concurrent('returns 500 for objects that are not Error instances', () => {
const result = categorizeError({ message: 'fake error' })
expect(result.status).toBe(500)
expect(result.message).toBe('Unknown error occurred')
})
})
describe('createMcpToolId', () => {
it.concurrent('creates tool ID from server ID and tool name', () => {
const toolId = createMcpToolId('mcp-12345678', 'my-tool')
expect(toolId).toBe('mcp-12345678-my-tool')
})
it.concurrent('adds mcp- prefix if server ID does not have it', () => {
const toolId = createMcpToolId('12345678', 'my-tool')
expect(toolId).toBe('mcp-12345678-my-tool')
})
it.concurrent('does not double-prefix if server ID already has mcp-', () => {
const toolId = createMcpToolId('mcp-server123', 'tool-name')
expect(toolId).toBe('mcp-server123-tool-name')
})
it.concurrent('handles tool names with hyphens', () => {
const toolId = createMcpToolId('mcp-server', 'my-complex-tool-name')
expect(toolId).toBe('mcp-server-my-complex-tool-name')
})
it.concurrent('handles empty tool name', () => {
const toolId = createMcpToolId('mcp-server', '')
expect(toolId).toBe('mcp-server-')
})
})
describe('parseMcpToolId', () => {
it.concurrent('parses valid MCP tool ID', () => {
const result = parseMcpToolId('mcp-12345678-my-tool')
expect(result.serverId).toBe('mcp-12345678')
expect(result.toolName).toBe('my-tool')
})
it.concurrent('parses tool name with hyphens', () => {
const result = parseMcpToolId('mcp-server123-my-complex-tool-name')
expect(result.serverId).toBe('mcp-server123')
expect(result.toolName).toBe('my-complex-tool-name')
})
it.concurrent('throws error for invalid format without mcp prefix', () => {
expect(() => parseMcpToolId('invalid-tool-id')).toThrow(
'Invalid MCP tool ID format: invalid-tool-id'
)
})
it.concurrent('throws error for tool ID with less than 3 parts', () => {
expect(() => parseMcpToolId('mcp-only')).toThrow('Invalid MCP tool ID format: mcp-only')
})
it.concurrent('throws error for empty string', () => {
expect(() => parseMcpToolId('')).toThrow('Invalid MCP tool ID format: ')
})
it.concurrent('throws error for single part', () => {
expect(() => parseMcpToolId('mcp')).toThrow('Invalid MCP tool ID format: mcp')
})
it.concurrent('handles tool name with multiple hyphens correctly', () => {
const result = parseMcpToolId('mcp-abc-tool-with-many-parts')
expect(result.serverId).toBe('mcp-abc')
expect(result.toolName).toBe('tool-with-many-parts')
})
})