Files
simstudioai--sim/apps/sim/lib/mcp/tool-validation.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

372 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
type DiscoveredTool,
getIssueBadgeLabel,
getMcpToolIssue,
hasSchemaChanged,
isToolUnavailable,
type McpToolIssue,
type ServerState,
} from './tool-validation'
import type { StoredMcpToolReference } from './types'
describe('hasSchemaChanged', () => {
it.concurrent('returns false when both schemas are undefined', () => {
expect(hasSchemaChanged(undefined, undefined)).toBe(false)
})
it.concurrent('returns false when stored schema is undefined', () => {
expect(hasSchemaChanged(undefined, { type: 'object' })).toBe(false)
})
it.concurrent('returns false when server schema is undefined', () => {
expect(hasSchemaChanged({ type: 'object' }, undefined)).toBe(false)
})
it.concurrent('returns false for identical schemas', () => {
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
expect(hasSchemaChanged(schema, { ...schema })).toBe(false)
})
it.concurrent('returns false when only description differs', () => {
const stored = {
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'Old description',
}
const server = {
type: 'object' as const,
properties: { name: { type: 'string' } },
description: 'New description',
}
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when properties differ', () => {
const stored = { type: 'object' as const, properties: { name: { type: 'string' } } }
const server = { type: 'object' as const, properties: { id: { type: 'number' } } }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when required fields differ', () => {
const stored = { type: 'object' as const, properties: {}, required: ['name'] }
const server = { type: 'object' as const, properties: {}, required: ['id'] }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns false for deep equal schemas with different key order', () => {
const stored = { type: 'object' as const, properties: { a: 1, b: 2 } }
const server = { properties: { b: 2, a: 1 }, type: 'object' as const }
expect(hasSchemaChanged(stored, server)).toBe(false)
})
it.concurrent('returns true when nested properties differ', () => {
const stored = {
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'boolean' } } } },
}
const server = {
type: 'object' as const,
properties: { config: { type: 'object', properties: { enabled: { type: 'string' } } } },
}
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('returns true when additional properties setting differs', () => {
const stored = { type: 'object' as const, additionalProperties: true }
const server = { type: 'object' as const, additionalProperties: false }
expect(hasSchemaChanged(stored, server)).toBe(true)
})
it.concurrent('ignores description at property level', () => {
const stored = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'Old' } },
}
const server = {
type: 'object' as const,
properties: { name: { type: 'string', description: 'New' } },
}
// Only top-level description is ignored, not nested ones
expect(hasSchemaChanged(stored, server)).toBe(true)
})
})
describe('getMcpToolIssue', () => {
const createStoredTool = (
overrides?: Partial<StoredMcpToolReference>
): StoredMcpToolReference => ({
serverId: 'server-1',
serverUrl: 'https://api.example.com/mcp',
toolName: 'test-tool',
schema: { type: 'object' },
...overrides,
})
const createServerState = (overrides?: Partial<ServerState>): ServerState => ({
id: 'server-1',
url: 'https://api.example.com/mcp',
connectionStatus: 'connected',
...overrides,
})
const createDiscoveredTool = (overrides?: Partial<DiscoveredTool>): DiscoveredTool => ({
serverId: 'server-1',
name: 'test-tool',
inputSchema: { type: 'object' },
...overrides,
})
describe('server_not_found', () => {
it.concurrent('returns server_not_found when server does not exist', () => {
const storedTool = createStoredTool()
const servers: ServerState[] = []
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
})
it.concurrent('returns server_not_found when server ID does not match', () => {
const storedTool = createStoredTool({ serverId: 'server-1' })
const servers = [createServerState({ id: 'server-2' })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_not_found', message: 'Server not found' })
})
})
describe('server_error', () => {
it.concurrent('returns server_error when server has error status', () => {
const storedTool = createStoredTool()
const servers = [
createServerState({ connectionStatus: 'error', lastError: 'Connection refused' }),
]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Connection refused' })
})
it.concurrent('returns server_error with default message when lastError is undefined', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: 'error', lastError: undefined })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server connection error' })
})
it.concurrent('returns server_error when server is disconnected', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: 'disconnected' })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
})
it.concurrent('returns server_error when connection status is undefined', () => {
const storedTool = createStoredTool()
const servers = [createServerState({ connectionStatus: undefined })]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'server_error', message: 'Server not connected' })
})
})
describe('url_changed', () => {
it.concurrent('returns url_changed when server URL has changed', () => {
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({
type: 'url_changed',
message: 'Server URL changed',
})
})
it.concurrent('does not return url_changed when stored URL is undefined', () => {
const storedTool = createStoredTool({ serverUrl: undefined })
const servers = [createServerState({ url: 'https://new.example.com/mcp' })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('does not return url_changed when server URL is undefined', () => {
const storedTool = createStoredTool({ serverUrl: 'https://old.example.com/mcp' })
const servers = [createServerState({ url: undefined })]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
describe('tool_not_found', () => {
it.concurrent('returns tool_not_found when tool does not exist on server', () => {
const storedTool = createStoredTool({ toolName: 'missing-tool' })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ name: 'other-tool' })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
it.concurrent('returns tool_not_found when tool exists on different server', () => {
const storedTool = createStoredTool({ serverId: 'server-1', toolName: 'test-tool' })
const servers = [createServerState({ id: 'server-1' })]
const tools = [createDiscoveredTool({ serverId: 'server-2', name: 'test-tool' })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
it.concurrent('returns tool_not_found when no tools are discovered', () => {
const storedTool = createStoredTool()
const servers = [createServerState()]
const tools: DiscoveredTool[] = []
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'tool_not_found', message: 'Tool not found on server' })
})
})
describe('schema_changed', () => {
it.concurrent('returns schema_changed when tool schema has changed', () => {
const storedTool = createStoredTool({
schema: { type: 'object', properties: { name: { type: 'string' } } },
})
const servers = [createServerState()]
const tools = [
createDiscoveredTool({
inputSchema: { type: 'object', properties: { id: { type: 'number' } } },
}),
]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toEqual({ type: 'schema_changed', message: 'Tool schema changed' })
})
it.concurrent('does not return schema_changed when stored schema is undefined', () => {
const storedTool = createStoredTool({ schema: undefined })
const servers = [createServerState()]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('does not return schema_changed when server schema is undefined', () => {
const storedTool = createStoredTool({ schema: { type: 'object' } })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ inputSchema: undefined })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
describe('no issues', () => {
it.concurrent('returns null when everything is valid', () => {
const storedTool = createStoredTool()
const servers = [createServerState()]
const tools = [createDiscoveredTool()]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
it.concurrent('returns null when schemas match exactly', () => {
const schema = { type: 'object' as const, properties: { name: { type: 'string' } } }
const storedTool = createStoredTool({ schema })
const servers = [createServerState()]
const tools = [createDiscoveredTool({ inputSchema: schema })]
const result = getMcpToolIssue(storedTool, servers, tools)
expect(result).toBeNull()
})
})
})
describe('getIssueBadgeLabel', () => {
it.concurrent('returns "stale" for schema_changed', () => {
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
expect(getIssueBadgeLabel(issue)).toBe('stale')
})
it.concurrent('returns "stale" for url_changed', () => {
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
expect(getIssueBadgeLabel(issue)).toBe('stale')
})
it.concurrent('returns "unavailable" for server_not_found', () => {
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
it.concurrent('returns "unavailable" for server_error', () => {
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
it.concurrent('returns "unavailable" for tool_not_found', () => {
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
expect(getIssueBadgeLabel(issue)).toBe('unavailable')
})
})
describe('isToolUnavailable', () => {
it.concurrent('returns false for null', () => {
expect(isToolUnavailable(null)).toBe(false)
})
it.concurrent('returns true for server_not_found', () => {
const issue: McpToolIssue = { type: 'server_not_found', message: 'Server not found' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns true for server_error', () => {
const issue: McpToolIssue = { type: 'server_error', message: 'Server error' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns true for tool_not_found', () => {
const issue: McpToolIssue = { type: 'tool_not_found', message: 'Tool not found' }
expect(isToolUnavailable(issue)).toBe(true)
})
it.concurrent('returns false for schema_changed', () => {
const issue: McpToolIssue = { type: 'schema_changed', message: 'Schema changed' }
expect(isToolUnavailable(issue)).toBe(false)
})
it.concurrent('returns false for url_changed', () => {
const issue: McpToolIssue = { type: 'url_changed', message: 'URL changed' }
expect(isToolUnavailable(issue)).toBe(false)
})
})