Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
aaac0e2618 fix: add oauth retry regression coverage (#48) (thanks @caseyg) 2026-03-03 00:01:41 +00:00
Casey Gollan
8940d13605 Discover OAuth scopes from protected resource metadata
Instead of hardcoding scope 'mcp:tools' during dynamic client
registration, fetch /.well-known/oauth-protected-resource from the
server to discover its supported scopes. This fixes InvalidScopeError
when connecting to servers like Todoist that only accept their own
scopes (e.g. 'data:read_write').

Also includes the fix from #38: remove the ad-hoc source restriction
in maybeEnableOAuth() so config-file servers can be promoted to OAuth.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-02 23:59:23 +00:00
Casey Gollan
ff89877666 Fix 405 misclassified as auth error in error-classifier
HTTP 405 (Method Not Allowed) was included in AUTH_STATUSES, causing it
to be treated as an authentication error. This leads to premature OAuth
session cleanup when StreamableHTTP returns 405, leaving the SSE
fallback without credentials. Remove 405 from AUTH_STATUSES so transport
errors are classified correctly.

Fixes #47

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-02 23:58:19 +00:00
6 changed files with 39 additions and 7 deletions

View File

@ -10,6 +10,7 @@
- OAuth wait/redirect now share one deferred to eliminate authorization race windows and preserve stable close-path errors, including wait-before-redirect and repeated-redirect flows. (PR #70, thanks @monotykamary)
- Added `--raw-strings` (numeric coercion off) and `--no-coerce` (all coercion off) for `mcporter call` argument parsing so IDs/codes can stay literal strings. (PR #59, thanks @nobrainer-tech)
- Added `CallResult.images()` plus opt-in `mcporter call --save-images <dir>` so image content blocks can be persisted without changing existing stdout output contracts. (PR #61, thanks @daniella-11ways)
- OAuth transport retries now classify HTTP 405 as HTTP (not auth) and OAuth promotion applies to configured HTTP servers too, so post-auth fallback flows no longer drop credentials on 405-only endpoints. (PR #48, thanks @caseyg)
### Tooling / Dependencies
- Updated dependencies to latest releases (including MCP SDK, Rolldown RC, Zod, Biome, Oxlint, Vitest, Bun types).

View File

@ -10,7 +10,7 @@ export interface ConnectionIssue {
stdioSignal?: string;
}
const AUTH_STATUSES = new Set([401, 403, 405]);
const AUTH_STATUSES = new Set([401, 403]);
const OFFLINE_PATTERNS = [
'fetch failed',
'econnrefused',

View File

@ -9,10 +9,8 @@ export function maybeEnableOAuth(definition: ServerDefinition, logger: Logger):
if (definition.command.kind !== 'http') {
return undefined;
}
const isAdHocSource = definition.source && definition.source.kind === 'local' && definition.source.path === '<adhoc>';
if (!isAdHocSource) {
return undefined;
}
// Allow OAuth promotion for any HTTP server that returns 401,
// not just ad-hoc servers (fixes issue #38)
logger.info(`Detected OAuth requirement for '${definition.name}'. Launching browser flow...`);
return {
...definition,

View File

@ -63,7 +63,7 @@ describe('CLI list classification and routing', () => {
expect(
logLines.some((line) => line.includes("vercel — Vercel MCP (auth required — run 'mcporter auth vercel'"))
).toBe(true);
expect(logLines.some((line) => line.includes("github (auth required — run 'mcporter auth github'"))).toBe(true);
expect(logLines.some((line) => line.includes('github') && line.includes('HTTP 405'))).toBe(true);
const nextDevtoolsLineFound = logLines.some(
(line) => line.startsWith('- next-devtools') && line.includes('offline — unable to reach server')
);
@ -75,6 +75,7 @@ describe('CLI list classification and routing', () => {
const summaryLine = logLines.find((line) => line.startsWith('✔ Listed'));
expect(summaryLine).toBeDefined();
expect(summaryLine).toContain('auth required');
expect(summaryLine).toContain('http errors');
expect(summaryLine).toContain('offline');
logSpy.mockRestore();

View File

@ -26,6 +26,18 @@ describe('analyzeConnectionError', () => {
expect(issue.statusCode).toBe(429);
});
it.each([401, 403] as const)('keeps %s classified as auth', (status) => {
const issue = analyzeConnectionError(new Error(`SSE error: Non-200 status code (${status})`));
expect(issue.kind).toBe('auth');
expect(issue.statusCode).toBe(status);
});
it('classifies HTTP 405 as transport/http instead of auth', () => {
const issue = analyzeConnectionError(new Error('SSE error: Non-200 status code (405)'));
expect(issue.kind).toBe('http');
expect(issue.statusCode).toBe(405);
});
it('extracts HTTP status codes from JSON payloads', () => {
const issue = analyzeConnectionError(new Error('{"error":{"status":503}}'));
expect(issue.kind).toBe('http');

View File

@ -25,13 +25,33 @@ describe('maybeEnableOAuth', () => {
expect(logger.info).toHaveBeenCalled();
});
it('does not mutate non-ad-hoc servers', () => {
it('enables OAuth for non-ad-hoc HTTP servers (issue #38)', () => {
const def: ServerDefinition = {
name: 'local-server',
command: { kind: 'http', url: new URL('https://example.com') },
source: { kind: 'local', path: '/tmp/config.json' },
};
const updated = maybeEnableOAuth(def, logger as never);
expect(updated).toBeDefined();
expect(updated?.auth).toBe('oauth');
});
it('does not mutate stdio servers', () => {
const def: ServerDefinition = {
name: 'stdio-server',
command: { kind: 'stdio', command: 'echo', args: [], cwd: process.cwd() },
};
const updated = maybeEnableOAuth(def, logger as never);
expect(updated).toBeUndefined();
});
it('does not re-promote servers already configured for oauth', () => {
const def: ServerDefinition = {
name: 'oauth-server',
auth: 'oauth',
command: { kind: 'http', url: new URL('https://example.com') },
};
const updated = maybeEnableOAuth(def, logger as never);
expect(updated).toBeUndefined();
});
});