200 lines
5.6 KiB
TypeScript
200 lines
5.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ResolvedMcpServer } from './capability-service'
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const client = {
|
|
connect: vi.fn(),
|
|
listTools: vi.fn(),
|
|
getServerVersion: vi.fn(),
|
|
getServerCapabilities: vi.fn(),
|
|
close: vi.fn()
|
|
}
|
|
return {
|
|
client,
|
|
Client: vi.fn(function Client() {
|
|
return client
|
|
}),
|
|
StdioClientTransport: vi.fn(function StdioClientTransport(
|
|
options: unknown
|
|
) {
|
|
return { kind: 'stdio', options }
|
|
}),
|
|
StreamableHTTPClientTransport: vi.fn(
|
|
function StreamableHTTPClientTransport(
|
|
url: URL,
|
|
options: unknown
|
|
) {
|
|
return { kind: 'http', url, options }
|
|
}
|
|
),
|
|
SSEClientTransport: vi.fn(function SSEClientTransport(
|
|
url: URL,
|
|
options: unknown
|
|
) {
|
|
return { kind: 'sse', url, options }
|
|
})
|
|
}
|
|
})
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
Client: mocks.Client
|
|
}))
|
|
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|
StdioClientTransport: mocks.StdioClientTransport
|
|
}))
|
|
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
|
StreamableHTTPClientTransport: mocks.StreamableHTTPClientTransport
|
|
}))
|
|
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
|
SSEClientTransport: mocks.SSEClientTransport
|
|
}))
|
|
|
|
import { testMcpServer } from './mcp-tester'
|
|
|
|
const common = {
|
|
id: 'd2ef774b-146c-4467-a909-6feb112a9c2c',
|
|
name: 'Test MCP',
|
|
description: '',
|
|
enabled: true,
|
|
allowDynamicTools: false,
|
|
assignments: ['model'] as Array<'model' | 'opencode' | 'continue'>,
|
|
secretConfigured: false
|
|
}
|
|
|
|
describe('testMcpServer', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.client.connect.mockResolvedValue(undefined)
|
|
mocks.client.listTools.mockResolvedValue({
|
|
tools: [
|
|
{
|
|
name: 'search',
|
|
description: 'Search documents'
|
|
}
|
|
]
|
|
})
|
|
mocks.client.getServerVersion.mockReturnValue({
|
|
name: 'test-server',
|
|
version: '1.0.0'
|
|
})
|
|
mocks.client.getServerCapabilities.mockReturnValue({
|
|
tools: { listChanged: false }
|
|
})
|
|
mocks.client.close.mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('uses separated stdio command arguments and closes the client', async () => {
|
|
const result = await testMcpServer({
|
|
...common,
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js', '--safe']
|
|
} satisfies ResolvedMcpServer)
|
|
|
|
expect(mocks.StdioClientTransport).toHaveBeenCalledWith({
|
|
command: 'node',
|
|
args: ['server.js', '--safe'],
|
|
stderr: 'ignore',
|
|
maxBufferSize: 2 * 1024 * 1024
|
|
})
|
|
expect(mocks.client.connect).toHaveBeenCalledOnce()
|
|
expect(mocks.client.listTools).toHaveBeenCalledOnce()
|
|
expect(mocks.client.close).toHaveBeenCalledOnce()
|
|
expect(result).toEqual({
|
|
serverName: 'test-server',
|
|
serverVersion: '1.0.0',
|
|
dynamicToolsSupported: false,
|
|
toolCount: 1,
|
|
tools: [{ name: 'search', description: 'Search documents' }]
|
|
})
|
|
})
|
|
|
|
it('reports support for dynamic tool-list notifications', async () => {
|
|
mocks.client.getServerCapabilities.mockReturnValue({
|
|
tools: { listChanged: true }
|
|
})
|
|
|
|
await expect(
|
|
testMcpServer({
|
|
...common,
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js']
|
|
} satisfies ResolvedMcpServer)
|
|
).resolves.toMatchObject({
|
|
dynamicToolsSupported: true
|
|
})
|
|
})
|
|
|
|
it('injects a bearer token only into the remote transport', async () => {
|
|
await testMcpServer({
|
|
...common,
|
|
transport: 'http',
|
|
url: 'https://mcp.example.com/mcp',
|
|
secretConfigured: true,
|
|
secret: 'test-secret'
|
|
} satisfies ResolvedMcpServer)
|
|
|
|
expect(mocks.StreamableHTTPClientTransport).toHaveBeenCalledOnce()
|
|
const [url, options] =
|
|
mocks.StreamableHTTPClientTransport.mock.calls[0] ?? []
|
|
expect(url).toEqual(new URL('https://mcp.example.com/mcp'))
|
|
expect(options).toMatchObject({
|
|
requestInit: {
|
|
headers: { Authorization: 'Bearer test-secret' }
|
|
},
|
|
reconnectionOptions: { maxRetries: 0 }
|
|
})
|
|
expect(options).not.toHaveProperty('fetch')
|
|
})
|
|
|
|
it('closes the client and returns a controlled error on failure', async () => {
|
|
mocks.client.connect.mockRejectedValue(
|
|
new Error('server included sensitive diagnostics')
|
|
)
|
|
|
|
await expect(
|
|
testMcpServer({
|
|
...common,
|
|
transport: 'sse',
|
|
url: 'https://mcp.example.com/sse'
|
|
} satisfies ResolvedMcpServer)
|
|
).rejects.toThrow(
|
|
'MCP Server 连接失败,请检查地址、命令和服务状态'
|
|
)
|
|
expect(mocks.client.close).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('accepts cancellation, closes the client, and hides abort details', async () => {
|
|
mocks.client.connect.mockImplementation(
|
|
async (
|
|
_transport: unknown,
|
|
options: { signal: AbortSignal }
|
|
) =>
|
|
new Promise((_resolve, reject) => {
|
|
options.signal.addEventListener(
|
|
'abort',
|
|
() => reject(new Error('sensitive abort reason')),
|
|
{ once: true }
|
|
)
|
|
})
|
|
)
|
|
const controller = new AbortController()
|
|
const pending = testMcpServer(
|
|
{
|
|
...common,
|
|
transport: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js']
|
|
} satisfies ResolvedMcpServer,
|
|
controller.signal
|
|
)
|
|
|
|
controller.abort(new Error('caller private context'))
|
|
|
|
await expect(pending).rejects.toThrow('MCP 连接测试已取消')
|
|
expect(mocks.client.listTools).not.toHaveBeenCalled()
|
|
expect(mocks.client.close).toHaveBeenCalledOnce()
|
|
})
|
|
})
|