299 lines
8.7 KiB
TypeScript
299 lines
8.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const connectMock = vi.fn();
|
|
const listToolsMock = vi.fn();
|
|
const callToolMock = vi.fn();
|
|
const listResourcesMock = vi.fn();
|
|
const clientInstances: unknown[] = [];
|
|
const streamableInstances: unknown[] = [];
|
|
const stdioInstances: unknown[] = [];
|
|
|
|
class MockClient {
|
|
constructor() {
|
|
clientInstances.push(this);
|
|
}
|
|
|
|
async connect(transport: { start?: () => Promise<void> }) {
|
|
connectMock(transport);
|
|
if (typeof transport.start === 'function') {
|
|
await transport.start();
|
|
}
|
|
}
|
|
|
|
async close() {}
|
|
|
|
async listTools(params: unknown) {
|
|
return listToolsMock(params);
|
|
}
|
|
|
|
async callTool(params: unknown) {
|
|
return callToolMock(params);
|
|
}
|
|
|
|
async listResources(params: unknown) {
|
|
return listResourcesMock(params);
|
|
}
|
|
}
|
|
|
|
class MockStreamableHTTPClientTransport {
|
|
public start = vi.fn(async () => {});
|
|
public close = vi.fn(async () => {});
|
|
constructor(
|
|
public url: URL,
|
|
public options?: unknown
|
|
) {
|
|
streamableInstances.push(this);
|
|
}
|
|
}
|
|
|
|
class MockSSEClientTransport {
|
|
public start = vi.fn(async () => {});
|
|
public close = vi.fn(async () => {});
|
|
constructor(
|
|
public url: URL,
|
|
public options?: unknown
|
|
) {}
|
|
}
|
|
|
|
class MockStdioClientTransport {
|
|
public start = vi.fn(async () => {});
|
|
public close = vi.fn(async () => {});
|
|
constructor(public options: unknown) {
|
|
stdioInstances.push(this);
|
|
}
|
|
}
|
|
|
|
class MockUnauthorizedError extends Error {}
|
|
|
|
return {
|
|
connectMock,
|
|
listToolsMock,
|
|
callToolMock,
|
|
listResourcesMock,
|
|
clientInstances,
|
|
streamableInstances,
|
|
stdioInstances,
|
|
MockClient,
|
|
MockStreamableHTTPClientTransport,
|
|
MockSSEClientTransport,
|
|
MockStdioClientTransport,
|
|
MockUnauthorizedError,
|
|
};
|
|
});
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
|
Client: mocks.MockClient,
|
|
}));
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
|
StreamableHTTPClientTransport: mocks.MockStreamableHTTPClientTransport,
|
|
}));
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
|
SSEClientTransport: mocks.MockSSEClientTransport,
|
|
}));
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
|
StdioClientTransport: mocks.MockStdioClientTransport,
|
|
}));
|
|
|
|
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
|
|
UnauthorizedError: mocks.MockUnauthorizedError,
|
|
}));
|
|
|
|
import { createRuntime } from '../src/runtime.js';
|
|
|
|
describe('mcporter composability', () => {
|
|
beforeEach(() => {
|
|
mocks.connectMock.mockClear();
|
|
mocks.listToolsMock.mockReset();
|
|
mocks.callToolMock.mockReset();
|
|
mocks.listResourcesMock.mockReset();
|
|
mocks.clientInstances.length = 0;
|
|
mocks.streamableInstances.length = 0;
|
|
mocks.stdioInstances.length = 0;
|
|
|
|
mocks.listToolsMock.mockResolvedValue({ tools: [] });
|
|
mocks.callToolMock.mockResolvedValue({ ok: true });
|
|
mocks.listResourcesMock.mockResolvedValue({ resources: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('reuses a single client connection for sequential calls', async () => {
|
|
mocks.listToolsMock.mockResolvedValueOnce({
|
|
tools: [{ name: 'echo', description: 'Echo tool' }],
|
|
});
|
|
mocks.callToolMock.mockResolvedValueOnce({ ok: 'first' }).mockResolvedValueOnce({ ok: 'second' });
|
|
|
|
const previousToken = process.env.INLINE_TOKEN;
|
|
process.env.INLINE_TOKEN = 'inline-test';
|
|
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'fake',
|
|
description: 'Inline fake server',
|
|
command: {
|
|
kind: 'http' as const,
|
|
url: new URL('https://example.com'),
|
|
headers: { Authorization: `Bearer \${INLINE_TOKEN}` },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
const tools = await runtime.listTools('fake');
|
|
expect(tools).toEqual([
|
|
{
|
|
name: 'echo',
|
|
description: 'Echo tool',
|
|
inputSchema: undefined,
|
|
outputSchema: undefined,
|
|
},
|
|
]);
|
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
|
expect(mocks.clientInstances).toHaveLength(1);
|
|
const streamableTransport = mocks.streamableInstances[0] as {
|
|
options?: {
|
|
requestInit?: { headers?: Record<string, string> };
|
|
authProvider?: unknown;
|
|
};
|
|
close: ReturnType<typeof vi.fn>;
|
|
};
|
|
expect(streamableTransport.options?.requestInit?.headers).toEqual({
|
|
Authorization: 'Bearer inline-test',
|
|
});
|
|
|
|
const first = await runtime.callTool('fake', 'echo', {
|
|
args: { text: 'hi' },
|
|
});
|
|
const second = await runtime.callTool('fake', 'echoSecond', {
|
|
args: { count: 2 },
|
|
});
|
|
|
|
expect(first).toEqual({ ok: 'first' });
|
|
expect(second).toEqual({ ok: 'second' });
|
|
expect(mocks.callToolMock).toHaveBeenNthCalledWith(1, {
|
|
name: 'echo',
|
|
arguments: { text: 'hi' },
|
|
});
|
|
expect(mocks.callToolMock).toHaveBeenNthCalledWith(2, {
|
|
name: 'echoSecond',
|
|
arguments: { count: 2 },
|
|
});
|
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
|
expect(mocks.clientInstances).toHaveLength(1);
|
|
} finally {
|
|
await runtime.close();
|
|
const streamableTransport = mocks.streamableInstances[0] as {
|
|
close: ReturnType<typeof vi.fn>;
|
|
};
|
|
expect(mocks.streamableInstances).toHaveLength(1);
|
|
expect(streamableTransport.close).toHaveBeenCalledTimes(1);
|
|
if (previousToken === undefined) {
|
|
delete process.env.INLINE_TOKEN;
|
|
} else {
|
|
process.env.INLINE_TOKEN = previousToken;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('passes the current process env to stdio transports', async () => {
|
|
vi.stubEnv('MCPORTER_STDIO_TEST', 'from-parent');
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'local',
|
|
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
|
source: { kind: 'local', path: '<test>' },
|
|
},
|
|
],
|
|
});
|
|
await runtime.callTool('local', 'echo', {});
|
|
const instance = mocks.stdioInstances.at(-1) as { options?: { env?: Record<string, string> } };
|
|
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent');
|
|
});
|
|
|
|
it('overrides inherited env vars with server-specific values', async () => {
|
|
vi.stubEnv('MCPORTER_STDIO_TEST', 'parent');
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'local',
|
|
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
|
env: { MCPORTER_STDIO_TEST: 'from-config', EXTRA: '42' },
|
|
source: { kind: 'local', path: '<test>' },
|
|
},
|
|
],
|
|
});
|
|
await runtime.callTool('local', 'echo', {});
|
|
const instance = mocks.stdioInstances.at(-1) as { options?: { env?: Record<string, string> } };
|
|
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-config');
|
|
expect(instance?.options?.env?.EXTRA).toBe('42');
|
|
});
|
|
});
|
|
|
|
describe('stdio transport environment', () => {
|
|
const previousEnv = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...previousEnv };
|
|
mocks.listToolsMock.mockReset();
|
|
mocks.listToolsMock.mockResolvedValue({ tools: [] });
|
|
mocks.clientInstances.length = 0;
|
|
mocks.stdioInstances.length = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...previousEnv };
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('resolves env overrides before spawning stdio transport', async () => {
|
|
process.env.OBSIDIAN_API_KEY = 'secret';
|
|
delete process.env.OBSIDIAN_BASE_URL;
|
|
|
|
const runtime = await createRuntime({
|
|
servers: [
|
|
{
|
|
name: 'obsidian',
|
|
description: 'Local Obsidian bridge',
|
|
command: {
|
|
kind: 'stdio' as const,
|
|
command: 'node',
|
|
args: ['--version'],
|
|
cwd: '/repo',
|
|
},
|
|
env: {
|
|
// Placeholders resolve against process env at runtime.
|
|
OBSIDIAN_API_KEY: '${OBSIDIAN_API_KEY}',
|
|
// Placeholders resolve against process env at runtime.
|
|
OBSIDIAN_BASE_URL: '${OBSIDIAN_BASE_URL:-https://127.0.0.1:27124}',
|
|
EMPTY_VAR: '',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
await runtime.listTools('obsidian');
|
|
expect(mocks.stdioInstances).toHaveLength(1);
|
|
const transport = mocks.stdioInstances[0] as { options: { env?: Record<string, string> } };
|
|
expect(transport.options.env).toEqual(
|
|
expect.objectContaining({
|
|
OBSIDIAN_API_KEY: 'secret',
|
|
OBSIDIAN_BASE_URL: 'https://127.0.0.1:27124',
|
|
})
|
|
);
|
|
} finally {
|
|
await runtime.close().catch(() => {});
|
|
}
|
|
});
|
|
});
|