Skip to content

Commit edc89f5

Browse files
alban bertoliniclaude
andcommitted
feat(ai-proxy): log MCP unreachable server errors as Warn instead of Error
When MCP servers are unreachable (connection refused, DNS not found, timeout, network unreachable), log as 'Warn' instead of 'Error' to reduce noise. All other errors (authentication, configuration, protocol errors) remain as 'Error'. Unreachable error codes: ECONNREFUSED, ENOTFOUND, ETIMEDOUT, ENETUNREACH, EHOSTUNREACH Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1fee39c commit edc89f5

2 files changed

Lines changed: 101 additions & 10 deletions

File tree

packages/ai-proxy/src/mcp-client.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
1-
import type { Logger } from '@forestadmin/datasource-toolkit';
1+
import type { Logger, LoggerLevel } from '@forestadmin/datasource-toolkit';
22

33
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
44

55
import { McpConnectionError } from './types/errors';
66
import McpServerRemoteTool from './types/mcp-server-remote-tool';
77

8+
const UNREACHABLE_ERROR_CODES = [
9+
'ECONNREFUSED',
10+
'ENOTFOUND',
11+
'ETIMEDOUT',
12+
'ENETUNREACH',
13+
'EHOSTUNREACH',
14+
];
15+
16+
function isServerUnreachable(error: Error): boolean {
17+
const errorCode = (error as NodeJS.ErrnoException).code;
18+
19+
if (errorCode && UNREACHABLE_ERROR_CODES.includes(errorCode)) {
20+
return true;
21+
}
22+
23+
const { message } = error;
24+
25+
return UNREACHABLE_ERROR_CODES.some(code => message.includes(code));
26+
}
27+
28+
function getLogLevelForError(error: Error): LoggerLevel {
29+
return isServerUnreachable(error) ? 'Warn' : 'Error';
30+
}
31+
832
export type McpConfiguration = {
933
configs: MultiServerMCPClient['config']['mcpServers'];
1034
} & Omit<MultiServerMCPClient['config'], 'mcpServers'>;
@@ -39,7 +63,8 @@ export default class McpClient {
3963
);
4064
this.tools.push(...extendedTools);
4165
} catch (error) {
42-
this.logger?.('Error', `Error loading tools for ${name}`, error as Error);
66+
const logLevel = getLogLevelForError(error as Error);
67+
this.logger?.(logLevel, `Error loading tools for ${name}`, error as Error);
4368
errors.push({ server: name, error: error as Error });
4469
}
4570
}),
@@ -48,8 +73,10 @@ export default class McpClient {
4873
// Surface partial failures to provide better feedback
4974
if (errors.length > 0) {
5075
const errorMessage = errors.map(e => `${e.server}: ${e.error.message}`).join('; ');
76+
const allConnectionErrors = errors.every(e => isServerUnreachable(e.error));
77+
const summaryLogLevel = allConnectionErrors ? 'Warn' : 'Error';
5178
this.logger?.(
52-
'Error',
79+
summaryLogLevel,
5380
`Failed to load tools from ${errors.length}/${Object.keys(this.mcpClients).length} ` +
5481
`MCP server(s): ${errorMessage}`,
5582
);
@@ -72,7 +99,8 @@ export default class McpClient {
7299
await this.closeConnections();
73100
} catch (cleanupError) {
74101
// Log but don't throw - we don't want to mask the original connection error
75-
this.logger?.('Error', 'Error during test connection cleanup', cleanupError as Error);
102+
const logLevel = getLogLevelForError(cleanupError as Error);
103+
this.logger?.(logLevel, 'Error during test connection cleanup', cleanupError as Error);
76104
}
77105
}
78106
}
@@ -88,14 +116,16 @@ export default class McpClient {
88116

89117
if (failures.length > 0) {
90118
failures.forEach(({ name, result }) => {
91-
this.logger?.(
92-
'Error',
93-
`Failed to close MCP connection for ${name}`,
94-
(result as PromiseRejectedResult).reason,
95-
);
119+
const error = (result as PromiseRejectedResult).reason;
120+
const logLevel = getLogLevelForError(error);
121+
this.logger?.(logLevel, `Failed to close MCP connection for ${name}`, error);
96122
});
123+
const allConnectionErrors = failures.every(({ result }) =>
124+
isServerUnreachable((result as PromiseRejectedResult).reason),
125+
);
126+
const summaryLogLevel = allConnectionErrors ? 'Warn' : 'Error';
97127
this.logger?.(
98-
'Error',
128+
summaryLogLevel,
99129
`Failed to close ${failures.length}/${results.length} MCP connections. ` +
100130
`This may result in resource leaks.`,
101131
);

packages/ai-proxy/test/mcp-client.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,67 @@ describe('McpClient', () => {
106106
expect(mcpClient.tools.length).toEqual(2);
107107
});
108108
});
109+
110+
describe('error logging levels', () => {
111+
it('should log unreachable server errors as Warn', async () => {
112+
const loggerMock = jest.fn();
113+
const mcpClient = new McpClient(aConfig, loggerMock);
114+
const unreachableError = new Error('ECONNREFUSED: Connection refused');
115+
getToolsMock.mockRejectedValue(unreachableError);
116+
117+
await mcpClient.loadTools();
118+
119+
expect(loggerMock).toHaveBeenCalledWith(
120+
'Warn',
121+
expect.stringContaining('Error loading tools for'),
122+
unreachableError,
123+
);
124+
expect(loggerMock).toHaveBeenCalledWith(
125+
'Warn',
126+
expect.stringContaining('Failed to load tools from'),
127+
);
128+
});
129+
130+
it('should log other errors as Error', async () => {
131+
const loggerMock = jest.fn();
132+
const mcpClient = new McpClient(aConfig, loggerMock);
133+
const otherError = new Error('Authentication failed');
134+
getToolsMock.mockRejectedValue(otherError);
135+
136+
await mcpClient.loadTools();
137+
138+
expect(loggerMock).toHaveBeenCalledWith(
139+
'Error',
140+
expect.stringContaining('Error loading tools for'),
141+
otherError,
142+
);
143+
expect(loggerMock).toHaveBeenCalledWith(
144+
'Error',
145+
expect.stringContaining('Failed to load tools from'),
146+
);
147+
});
148+
149+
it.each([
150+
['ECONNREFUSED', 'ECONNREFUSED: Connection refused'],
151+
['ENOTFOUND', 'ENOTFOUND: getaddrinfo ENOTFOUND hostname'],
152+
['ETIMEDOUT', 'ETIMEDOUT: Connection timed out'],
153+
['ENETUNREACH', 'ENETUNREACH: Network is unreachable'],
154+
['EHOSTUNREACH', 'EHOSTUNREACH: No route to host'],
155+
])('should log %s errors as Warn', async (_, errorMessage) => {
156+
const loggerMock = jest.fn();
157+
const mcpClient = new McpClient(aConfig, loggerMock);
158+
const error = new Error(errorMessage);
159+
getToolsMock.mockRejectedValue(error);
160+
161+
await mcpClient.loadTools();
162+
163+
expect(loggerMock).toHaveBeenCalledWith(
164+
'Warn',
165+
expect.stringContaining('Error loading tools for'),
166+
error,
167+
);
168+
});
169+
});
109170
});
110171

111172
describe('closeConnection', () => {

0 commit comments

Comments
 (0)