fix: detect auth errors from error.code property (StreamableHTTPError/SseError)

MCP SDK's StreamableHTTPError/SseError store the HTTP status code in
error.code, but the message text may not contain the numeric status.
analyzeConnectionError now reads error.code (100-599) before falling
back to message-text parsing, so OAuth promotion triggers correctly
for 401 responses on Streamable HTTP transport.
This commit is contained in:
yukaibo.me 2026-03-03 21:27:34 +08:00 committed by Peter Steinberger
parent 8af1a77c45
commit 049f3b7d6b
No known key found for this signature in database
3 changed files with 53 additions and 1 deletions

View File

@ -47,7 +47,8 @@ export function analyzeConnectionError(error: unknown): ConnectionIssue {
if (stdio) {
return { kind: 'stdio-exit', rawMessage, ...stdio };
}
const statusCode = extractStatusCode(rawMessage);
const errorCode = extractErrorCode(error);
const statusCode = errorCode ?? extractStatusCode(rawMessage);
const normalized = rawMessage.toLowerCase();
if (AUTH_STATUSES.has(statusCode ?? -1) || containsAuthToken(normalized)) {
return { kind: 'auth', rawMessage, statusCode };
@ -82,6 +83,16 @@ function extractMessage(error: unknown): string {
}
}
function extractErrorCode(error: unknown): number | undefined {
if (typeof error === 'object' && error !== null && 'code' in error) {
const code = (error as Record<string, unknown>).code;
if (typeof code === 'number' && Number.isFinite(code) && code >= 100 && code < 600) {
return code;
}
}
return undefined;
}
function extractStatusCode(message: string): number | undefined {
const candidates = [
message.match(/status code\s*\((\d{3})\)/i)?.[1],

View File

@ -43,4 +43,40 @@ describe('analyzeConnectionError', () => {
expect(issue.kind).toBe('http');
expect(issue.statusCode).toBe(503);
});
describe('error.code property (StreamableHTTPError / SseError)', () => {
it('classifies code=401 as auth even when message lacks 401', () => {
const err = Object.assign(new Error('Error POSTing to endpoint: {}'), { code: 401 });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('auth');
expect(issue.statusCode).toBe(401);
});
it('classifies code=403 as auth', () => {
const err = Object.assign(new Error('Forbidden'), { code: 403 });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('auth');
expect(issue.statusCode).toBe(403);
});
it('classifies code=404 as http (not auth)', () => {
const err = Object.assign(new Error('Not Found'), { code: 404 });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('http');
expect(issue.statusCode).toBe(404);
});
it('classifies code=500 as http', () => {
const err = Object.assign(new Error('Internal Server Error'), { code: 500 });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('http');
expect(issue.statusCode).toBe(500);
});
it('falls back to message parsing when code is absent', () => {
const issue = analyzeConnectionError(new Error('network timeout'));
expect(issue.kind).toBe('offline');
expect(issue.statusCode).toBeUndefined();
});
});
});

View File

@ -69,4 +69,9 @@ describe('isUnauthorizedError helper', () => {
it('ignores unrelated errors', () => {
expect(isUnauthorizedError(new Error('network timeout'))).toBe(false);
});
it('matches errors with code=401 even when message lacks 401', () => {
const err = Object.assign(new Error('Error POSTing to endpoint: {}'), { code: 401 });
expect(isUnauthorizedError(err)).toBe(true);
});
});