Skip to content

Commit 9d5c638

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 9d5c638

2 files changed

Lines changed: 99 additions & 10 deletions

File tree

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
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: string[] = [
9+
'ECONNREFUSED',
10+
'ENOTFOUND',
11+
'ETIMEDOUT',
12+
'ENETUNREACH',
13+
'EHOSTUNREACH',
14+
];
15+
16+
function isServerUnreachable(error: Error): boolean {
17+
const { code } = error as NodeJS.ErrnoException;
18+
19+
return !!code && UNREACHABLE_ERROR_CODES.includes(code);
20+
}
21+
22+
function getLogLevelForError(error: Error): LoggerLevel {
23+
return isServerUnreachable(error) ? 'Warn' : 'Error';
24+
}
25+
826
export type McpConfiguration = {
927
configs: MultiServerMCPClient['config']['mcpServers'];
1028
} & Omit<MultiServerMCPClient['config'], 'mcpServers'>;
@@ -39,7 +57,8 @@ export default class McpClient {
3957
);
4058
this.tools.push(...extendedTools);
4159
} catch (error) {
42-
this.logger?.('Error', `Error loading tools for ${name}`, error as Error);
60+
const logLevel = getLogLevelForError(error as Error);
61+
this.logger?.(logLevel, `Error loading tools for ${name}`, error as Error);
4362
errors.push({ server: name, error: error as Error });
4463
}
4564
}),
@@ -48,8 +67,10 @@ export default class McpClient {
4867
// Surface partial failures to provide better feedback
4968
if (errors.length > 0) {
5069
const errorMessage = errors.map(e => `${e.server}: ${e.error.message}`).join('; ');
70+
const allConnectionErrors = errors.every(e => isServerUnreachable(e.error));
71+
const summaryLogLevel = allConnectionErrors ? 'Warn' : 'Error';
5172
this.logger?.(
52-
'Error',
73+
summaryLogLevel,
5374
`Failed to load tools from ${errors.length}/${Object.keys(this.mcpClients).length} ` +
5475
`MCP server(s): ${errorMessage}`,
5576
);
@@ -72,7 +93,8 @@ export default class McpClient {
7293
await this.closeConnections();
7394
} catch (cleanupError) {
7495
// 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);
96+
const logLevel = getLogLevelForError(cleanupError as Error);
97+
this.logger?.(logLevel, 'Error during test connection cleanup', cleanupError as Error);
7698
}
7799
}
78100
}
@@ -88,14 +110,16 @@ export default class McpClient {
88110

89111
if (failures.length > 0) {
90112
failures.forEach(({ name, result }) => {
91-
this.logger?.(
92-
'Error',
93-
`Failed to close MCP connection for ${name}`,
94-
(result as PromiseRejectedResult).reason,
95-
);
113+
const error = (result as PromiseRejectedResult).reason;
114+
const logLevel = getLogLevelForError(error);
115+
this.logger?.(logLevel, `Failed to close MCP connection for ${name}`, error);
96116
});
117+
const allConnectionErrors = failures.every(({ result }) =>
118+
isServerUnreachable((result as PromiseRejectedResult).reason),
119+
);
120+
const summaryLogLevel = allConnectionErrors ? 'Warn' : 'Error';
97121
this.logger?.(
98-
'Error',
122+
summaryLogLevel,
99123
`Failed to close ${failures.length}/${results.length} MCP connections. ` +
100124
`This may result in resource leaks.`,
101125
);

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

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

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

0 commit comments

Comments
 (0)