File size: 6,827 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | import { afterEach, describe, expect, it } from 'vitest';
import { ErrorCodes, Error2 } from '#/errors';
import { buildMcpHttpHeaders, HttpMcpClient, isTerminalTransportError } from '#/mcpCore/client-http';
import { startInProcessHttpMcpServer } from './stubs';
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) {
await cleanup();
}
});
function expectConfigInvalid(fn: () => unknown): void {
try {
fn();
} catch (error) {
expect(error).toBeInstanceOf(Error2);
expect((error as Error2).code).toBe(ErrorCodes.CONFIG_INVALID);
return;
}
throw new Error('expected function to throw');
}
describe('buildMcpHttpHeaders', () => {
it('returns undefined when no headers and no bearer are configured', () => {
expect(
buildMcpHttpHeaders({ transport: 'http', url: 'https://x.example.com' }, () => undefined),
).toBeUndefined();
});
it('passes through configured static headers', () => {
expect(
buildMcpHttpHeaders(
{ transport: 'http', url: 'https://x.example.com', headers: { 'X-Tenant': 'kimi' } },
() => undefined,
),
).toEqual({ 'X-Tenant': 'kimi' });
});
it('injects Authorization Bearer when env lookup yields a token', () => {
expect(
buildMcpHttpHeaders(
{ transport: 'http', url: 'https://x.example.com', bearerTokenEnvVar: 'TOK' },
(name) => (name === 'TOK' ? 'secret' : undefined),
),
).toEqual({ Authorization: 'Bearer secret' });
});
it('throws Error2(config.invalid) when a configured bearer token env var is empty or missing', () => {
expectConfigInvalid(() =>
buildMcpHttpHeaders(
{ transport: 'http', url: 'https://x.example.com', bearerTokenEnvVar: 'MISSING' },
() => undefined,
),
);
expect(() =>
buildMcpHttpHeaders(
{ transport: 'http', url: 'https://x.example.com', bearerTokenEnvVar: 'MISSING' },
() => undefined,
),
).toThrow(/"MISSING" is not set or is empty/);
expectConfigInvalid(() =>
buildMcpHttpHeaders(
{ transport: 'http', url: 'https://x.example.com', bearerTokenEnvVar: 'EMPTY' },
() => '',
),
);
});
it('merges bearer over the same Authorization key from static headers', () => {
expect(
buildMcpHttpHeaders(
{
transport: 'http',
url: 'https://x.example.com',
headers: { Authorization: 'Bearer stale', 'X-Trace': '1' },
bearerTokenEnvVar: 'TOK',
},
() => 'fresh',
),
).toEqual({ Authorization: 'Bearer fresh', 'X-Trace': '1' });
});
it('strips case-variant authorization headers before injecting the bearer', () => {
expect(
buildMcpHttpHeaders(
{
transport: 'http',
url: 'https://x.example.com',
headers: { authorization: 'Bearer stale', AUTHORIZATION: 'Bearer older', 'X-Trace': '1' },
bearerTokenEnvVar: 'TOK',
},
() => 'fresh',
),
).toEqual({ Authorization: 'Bearer fresh', 'X-Trace': '1' });
});
it('flags errors the SDK uses to signal a dead HTTP transport as terminal', () => {
const unauthorized = new Error('Unauthorized');
unauthorized.name = 'UnauthorizedError';
expect(isTerminalTransportError(unauthorized)).toBe(true);
expect(isTerminalTransportError(new Error('Maximum reconnection attempts (3) exceeded.'))).toBe(
true,
);
});
it('does not flag transient SDK errors as terminal', () => {
expect(isTerminalTransportError(new Error('SSE stream disconnected: ECONNRESET'))).toBe(false);
expect(isTerminalTransportError(new Error('fetch failed'))).toBe(false);
expect(isTerminalTransportError(new Error('Connection closed'))).toBe(false);
});
});
describe('HttpMcpClient', () => {
it('connects, lists tools, and round-trips a call over real HTTP', async () => {
const server = await startInProcessHttpMcpServer();
cleanups.push(server.close);
const client = new HttpMcpClient({ transport: 'http', url: server.url });
try {
await client.connect();
const tools = await client.listTools();
expect(tools.map((t) => t.name)).toEqual(['echo']);
const result = await client.callTool('echo', { text: 'hello http' });
expect(result.isError).toBe(false);
expect(result.content).toEqual([{ type: 'text', text: 'hello http' }]);
} finally {
await client.close();
}
}, 15000);
it('flips to unexpected-close when the SDK signals a terminal transport error', async () => {
const server = await startInProcessHttpMcpServer();
cleanups.push(server.close);
const client = new HttpMcpClient({ transport: 'http', url: server.url });
const closes: Array<{ error?: string }> = [];
client.onUnexpectedClose((reason) => {
closes.push({ error: reason.error?.message });
});
try {
await client.connect();
const internal = (client as unknown as {
client: { onerror?: (error: Error) => void };
}).client;
internal.onerror?.(new Error('Maximum reconnection attempts (3) exceeded.'));
await new Promise((r) => setTimeout(r, 25));
expect(closes).toHaveLength(1);
expect(closes[0]?.error).toContain('Maximum reconnection attempts');
} finally {
await client.close();
}
}, 15000);
it('ignores transient SDK errors that the transport recovers from', async () => {
const server = await startInProcessHttpMcpServer();
cleanups.push(server.close);
const client = new HttpMcpClient({ transport: 'http', url: server.url });
const closes: number[] = [];
client.onUnexpectedClose(() => closes.push(Date.now()));
try {
await client.connect();
const internal = (client as unknown as {
client: { onerror?: (error: Error) => void };
}).client;
internal.onerror?.(new Error('SSE stream disconnected: ECONNRESET'));
internal.onerror?.(new Error('fetch failed'));
await new Promise((r) => setTimeout(r, 25));
expect(closes).toEqual([]);
} finally {
await client.close();
}
}, 15000);
it('forwards bearer token from envLookup', async () => {
const server = await startInProcessHttpMcpServer({ authToken: 'good-token' });
cleanups.push(server.close);
const client = new HttpMcpClient(
{
transport: 'http',
url: server.url,
bearerTokenEnvVar: 'EXAMPLE_TOKEN',
},
{ envLookup: (name) => (name === 'EXAMPLE_TOKEN' ? 'good-token' : undefined) },
);
try {
await client.connect();
const tools = await client.listTools();
expect(tools.map((t) => t.name)).toEqual(['echo']);
} finally {
await client.close();
}
}, 15000);
});
|