mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 07:27:57 -05:00
382 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|