stdioProxyTransport.test.ts•9.16 kB
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StdioProxyTransport } from './stdioProxyTransport.js';
// Mock the SDK transports
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn(() => ({
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
onmessage: undefined,
onerror: undefined,
onclose: undefined,
})),
}));
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
StreamableHTTPClientTransport: vi.fn(() => ({
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
onmessage: undefined,
onerror: undefined,
onclose: undefined,
})),
}));
describe('StdioProxyTransport', () => {
let proxy: StdioProxyTransport;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(async () => {
if (proxy) {
await proxy.close();
}
});
describe('constructor', () => {
it('should create proxy with server URL', () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
expect(proxy).toBeDefined();
});
it('should create proxy with tags', () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
tags: ['web', 'api'],
});
expect(proxy).toBeDefined();
});
it('should create proxy with timeout', () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
timeout: 5000,
});
expect(proxy).toBeDefined();
});
});
describe('start', () => {
it('should start both transports in correct order', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Verify both transports were started
expect(proxy['httpTransport'].start).toHaveBeenCalled();
expect(proxy['stdioTransport'].start).toHaveBeenCalled();
});
it('should set up message forwarding before starting transports', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Verify message handlers are set
expect(proxy['stdioTransport'].onmessage).toBeDefined();
expect(proxy['httpTransport'].onmessage).toBeDefined();
expect(proxy['stdioTransport'].onerror).toBeDefined();
expect(proxy['httpTransport'].onerror).toBeDefined();
expect(proxy['stdioTransport'].onclose).toBeDefined();
expect(proxy['httpTransport'].onclose).toBeDefined();
});
});
describe('message forwarding', () => {
it('should forward messages from STDIO to HTTP', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'initialize',
id: 1,
params: {},
};
// Simulate STDIO message
await proxy['stdioTransport'].onmessage!(message);
// Verify forwarded to HTTP transport
expect(proxy['httpTransport'].send).toHaveBeenCalledWith(message);
});
it('should forward messages from HTTP to STDIO', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const message: JSONRPCMessage = {
jsonrpc: '2.0',
result: { capabilities: {} },
id: 1,
};
// Simulate HTTP message
await proxy['httpTransport'].onmessage!(message);
// Verify forwarded to STDIO transport
expect(proxy['stdioTransport'].send).toHaveBeenCalledWith(message);
});
it('should handle errors during STDIO to HTTP forwarding', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Make send throw error
proxy['httpTransport'].send = vi.fn().mockRejectedValue(new Error('Send failed'));
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
id: 1,
params: {},
};
// Should not throw, error should be logged
await expect(proxy['stdioTransport'].onmessage!(message)).resolves.not.toThrow();
});
it('should handle errors during HTTP to STDIO forwarding', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Make send throw error
proxy['stdioTransport'].send = vi.fn().mockRejectedValue(new Error('Send failed'));
const message: JSONRPCMessage = {
jsonrpc: '2.0',
result: {},
id: 1,
};
// Should not throw, error should be logged
await expect(proxy['httpTransport'].onmessage!(message)).resolves.not.toThrow();
});
});
describe('close', () => {
it('should close both transports', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
await proxy.close();
expect(proxy['httpTransport'].close).toHaveBeenCalled();
expect(proxy['stdioTransport'].close).toHaveBeenCalled();
});
it('should handle close when not connected', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
// Should not throw when closing without starting
await expect(proxy.close()).resolves.not.toThrow();
});
it('should handle close errors gracefully', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Make close throw error
proxy['httpTransport'].close = vi.fn().mockRejectedValue(new Error('Close failed'));
// Should not throw, error should be logged
await expect(proxy.close()).resolves.not.toThrow();
});
it('should not cause infinite recursion when onclose handlers trigger', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
// Track how many times the actual cleanup logic runs
let cleanupExecutions = 0;
// Make transports trigger their onclose handlers when close() is called
const httpCloseMock = vi.fn(async () => {
cleanupExecutions++;
// Simulate real transport behavior: trigger onclose when closed
if (proxy['httpTransport'].onclose) {
await proxy['httpTransport'].onclose();
}
});
const stdioCloseMock = vi.fn(async () => {
cleanupExecutions++;
// Simulate real transport behavior: trigger onclose when closed
if (proxy['stdioTransport'].onclose) {
await proxy['stdioTransport'].onclose();
}
});
proxy['httpTransport'].close = httpCloseMock;
proxy['stdioTransport'].close = stdioCloseMock;
// This should not cause stack overflow or throw error
await expect(proxy.close()).resolves.not.toThrow();
// Cleanup should execute exactly once (both transports closed once)
expect(httpCloseMock).toHaveBeenCalledTimes(1);
expect(stdioCloseMock).toHaveBeenCalledTimes(1);
expect(cleanupExecutions).toBe(2); // One for http, one for stdio
});
});
describe('transport lifecycle handlers', () => {
it('should handle STDIO transport close', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const closeSpy = vi.spyOn(proxy, 'close');
// Trigger STDIO close
await proxy['stdioTransport'].onclose!();
expect(closeSpy).toHaveBeenCalled();
});
it('should handle HTTP transport close', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const closeSpy = vi.spyOn(proxy, 'close');
// Trigger HTTP close
await proxy['httpTransport'].onclose!();
expect(closeSpy).toHaveBeenCalled();
});
it('should handle STDIO transport errors', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const error = new Error('STDIO error');
// Should not throw
expect(() => proxy['stdioTransport'].onerror!(error)).not.toThrow();
});
it('should handle HTTP transport errors', async () => {
proxy = new StdioProxyTransport({
serverUrl: 'http://localhost:3050/mcp',
});
await proxy.start();
const error = new Error('HTTP error');
// Should not throw
expect(() => proxy['httpTransport'].onerror!(error)).not.toThrow();
});
});
});