Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
a02dda1b44
fix: handle auth status codes from error metadata (#94) (thanks @KentonYu) 2026-03-28 19:20:55 +00:00
yukaibo.me
049f3b7d6b
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.
2026-03-28 19:17:48 +00:00
4 changed files with 79 additions and 1 deletions

View File

@ -3,6 +3,7 @@
## [Unreleased]
### CLI
- Detect auth failures from `error.code` metadata too, so Streamable HTTP/SSE transports still trigger OAuth correctly when messages omit the numeric status. (PR #94, thanks @KentonYu)
- Preserve default imports when `mcporter config add` writes a config file, instead of forcing `"imports": []`.
- OAuth: avoid crashing on headless Linux when `xdg-open` is unavailable; clear stale dynamic-port client registrations; close callback server if stale-client persistence reads fail. (PR #72, thanks @mgonto)
- Added optional `oauthScope`/`oauth_scope` config override as an escape hatch for providers that require explicit scopes.

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,22 @@ 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;
}
if (typeof code === 'string') {
const parsed = Number.parseInt(code, 10);
if (Number.isFinite(parsed) && parsed >= 100 && parsed < 600) {
return parsed;
}
}
}
return undefined;
}
function extractStatusCode(message: string): number | undefined {
const candidates = [
message.match(/status code\s*\((\d{3})\)/i)?.[1],

View File

@ -43,4 +43,54 @@ 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('accepts numeric-string HTTP codes from SDK errors', () => {
const err = Object.assign(new Error('Forbidden'), { code: '403' });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('auth');
expect(issue.statusCode).toBe(403);
});
it('ignores non-http string codes like errno values', () => {
const err = Object.assign(new Error('spawn enoent'), { code: 'ENOENT' });
const issue = analyzeConnectionError(err);
expect(issue.kind).toBe('offline');
expect(issue.statusCode).toBeUndefined();
});
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,14 @@ 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);
});
it('matches errors with string code=401 too', () => {
const err = Object.assign(new Error('Error POSTing to endpoint: {}'), { code: '401' });
expect(isUnauthorizedError(err)).toBe(true);
});
});