Skip to content

Commit 02cbb92

Browse files
jancurnclaude
andauthored
Explain OAuth client-registration failures during login (e.g. Figma 403) instead of leaking SDK JSON parse errors (#298)
`mcpc login` against a server that refuses Dynamic Client Registration (e.g. Figma's remote MCP server, which returns a plain-text `403 Forbidden` for clients not on its allow-list) surfaced the SDK's raw parse error (`HTTP 403: Invalid OAuth error response: SyntaxError: ... Raw body: Forbidden`). Registration-phase failures are now rewritten into actionable guidance pointing at `--client-id` / `--client-metadata-url`. - Registration phase is detected via the SDK's typed `OAuthError` plus a `reachedAuthorization` flag; post-redirect errors pass through unchanged - Allow-list wording only for 401/403 or OAuth-coded client rejections; other registration failures (5xx outages, rejected metadata) are reported honestly - The server's underlying response stays visible in the message (truncated), since `login` prints only `error.message` Fixes #296, supersedes #297. https://claude.ai/code/session_01AP2pmUcWvR8rNypXqNEqtk --- _Generated by [Claude Code](https://claude.ai/code/session_01AP2pmUcWvR8rNypXqNEqtk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a11dadb commit 02cbb92

3 files changed

Lines changed: 256 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `mcpc login` now explains OAuth client-registration failures with actionable guidance (use `--client-id` or `--client-metadata-url`) instead of surfacing a raw SDK JSON parse error. This affects servers that reject Dynamic Client Registration, such as Figma's remote MCP server, which only accepts an allow-list of approved clients.
13+
1014
## [0.4.0] - 2026-06-25
1115

1216
### Added

src/lib/auth/oauth-flow.ts

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { URL } from 'url';
1010
import { createInterface } from 'readline';
1111
import { randomBytes } from 'crypto';
1212
import { auth as sdkAuth } from '@modelcontextprotocol/sdk/client/auth.js';
13+
import { OAuthError as SdkOAuthError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
1314
import { OAuthProvider, type OAuthProviderOptions } from './oauth-provider.js';
1415
import { getServerHost } from '../utils.js';
15-
import { ClientError } from '../errors.js';
16+
import { AuthError, ClientError } from '../errors.js';
1617
import { createLogger } from '../logger.js';
18+
import { describeAuthError } from './client-credentials.js';
1719
import { removeKeychainOAuthClientInfo, storeKeychainOAuthClientInfo } from './keychain.js';
1820
import type { AuthProfile } from '../types.js';
1921
import {
@@ -25,6 +27,118 @@ import { renderAuthPage } from './auth-page.js';
2527

2628
const logger = createLogger('oauth-flow');
2729

30+
/** Matches the MCP SDK error thrown when a server exposes no `registration_endpoint`. */
31+
const DCR_UNSUPPORTED_PATTERN = /does not support dynamic client registration/i;
32+
33+
/**
34+
* OAuth `error` codes that mean the authorization server rejected mcpc as a
35+
* client, as opposed to a malformed request or a server-side fault.
36+
*/
37+
const CLIENT_REJECTED_CODES = new Set(['invalid_client', 'unauthorized_client', 'access_denied']);
38+
39+
/**
40+
* Cap on how much of the server's error response is echoed back to the user.
41+
* The SDK embeds the full raw response body in its message, which can be an
42+
* entire HTML error page.
43+
*/
44+
const MAX_DETAIL_LENGTH = 400;
45+
46+
/**
47+
* Rewrite a raw MCP SDK OAuth error into an actionable one when the failure
48+
* happened during dynamic client registration (DCR) — that is, before the user
49+
* was ever redirected to the authorization endpoint.
50+
*
51+
* Many hosted MCP servers do not allow open DCR: they either expose no
52+
* `registration_endpoint`, or the endpoint rejects unknown clients. Figma's
53+
* remote MCP server is a concrete example — its registration endpoint returns
54+
* a bare `403 Forbidden` for any client not on its approved allow-list.
55+
* Because that body is not valid OAuth-error JSON, the SDK surfaces it as the
56+
* opaque `HTTP 403: Invalid OAuth error response: ... Raw body: Forbidden`,
57+
* which gives the user no idea what to do next. Retrying cannot help; the user
58+
* must supply pre-registered client credentials (or a custom CIMD document).
59+
*
60+
* Phase detection: before the authorization redirect the only SDK request that
61+
* surfaces {@link SdkOAuthError} is client registration — discovery failures
62+
* are swallowed by the SDK, and the token exchange (plus its refresh variant,
63+
* disabled here by `forceReauth`) only runs after the redirect. Errors thrown
64+
* after the redirect, and pre-redirect errors unrelated to registration
65+
* (network faults, user cancellation), are returned unchanged so their own
66+
* messaging is preserved.
67+
*
68+
* @param error - The error thrown by the SDK auth flow.
69+
* @param context - The normalized server URL and whether the flow reached the
70+
* authorization redirect.
71+
* @returns An {@link AuthError} with remediation guidance when the failure is
72+
* registration-related, otherwise the original error unchanged.
73+
*/
74+
export function explainOAuthRegistrationFailure(
75+
error: unknown,
76+
context: { serverUrl: string; reachedAuthorization: boolean }
77+
): unknown {
78+
// A failure after the authorization redirect (token exchange, user
79+
// cancellation, callback problems) is not a registration problem.
80+
if (context.reachedAuthorization) {
81+
return error;
82+
}
83+
84+
const detail = describeAuthError(error);
85+
const dcrUnsupported = DCR_UNSUPPORTED_PATTERN.test(detail);
86+
const isRegistrationError = error instanceof SdkOAuthError;
87+
// The HTTP status only appears in the message for non-JSON error bodies
88+
// (the SDK's `Invalid OAuth error response` case); OAuth-coded rejections
89+
// carry a machine-readable errorCode instead. Keeping the status fallback
90+
// independent of the instanceof check also preserves the rewrite if the SDK
91+
// ever stops throwing typed errors from the registration path.
92+
const status = /\bHTTP (\d{3})\b/.exec(detail)?.[1];
93+
const rejectedByServer =
94+
status === '401' ||
95+
status === '403' ||
96+
(error instanceof SdkOAuthError && CLIENT_REJECTED_CODES.has(error.errorCode));
97+
98+
if (!dcrUnsupported && !isRegistrationError && !rejectedByServer) {
99+
return error;
100+
}
101+
102+
const host = getServerHost(context.serverUrl);
103+
const truncatedDetail =
104+
detail.length > MAX_DETAIL_LENGTH ? `${detail.slice(0, MAX_DETAIL_LENGTH)}…` : detail;
105+
106+
const lines: string[] = [];
107+
if (dcrUnsupported) {
108+
lines.push(
109+
`${host} does not support Dynamic Client Registration, and no pre-registered ` +
110+
`client credentials were provided.`
111+
);
112+
} else if (rejectedByServer) {
113+
lines.push(
114+
`${host} refused to register mcpc as an OAuth client${status ? ` (HTTP ${status})` : ''}.`,
115+
`Some MCP servers only accept a fixed allow-list of approved clients and reject ` +
116+
`Dynamic Client Registration from everyone else (Figma's remote MCP server behaves ` +
117+
`this way). When that happens there is no self-service client for mcpc to register.`
118+
);
119+
} else {
120+
// Any other registration failure (e.g. a 5xx outage or a rejected client
121+
// metadata field): report it honestly without claiming an allow-list.
122+
lines.push(`Client registration with ${host} failed: ${truncatedDetail}`);
123+
}
124+
125+
lines.push(
126+
'',
127+
'To authenticate, supply a client the server already recognizes:',
128+
` • pre-registered client: mcpc login ${host} --client-id <id> [--client-secret <secret>]`,
129+
` • custom CIMD document: mcpc login ${host} --client-metadata-url <https-url>`,
130+
'',
131+
`If the server restricts MCP access to specific approved clients, check with the ` +
132+
`provider whether third-party clients such as mcpc are supported.`
133+
);
134+
135+
if (rejectedByServer && !dcrUnsupported) {
136+
lines.push('', `Server response: ${truncatedDetail}`);
137+
}
138+
139+
return new AuthError(lines.join('\n'), { originalError: detail });
140+
}
141+
28142
// Special key codes
29143
const ESCAPE_KEY = '\x1b';
30144
const CTRL_C = '\x03';
@@ -498,6 +612,11 @@ export async function performOAuthFlow(
498612
// Track whether browser failed so we can fall back to URL paste
499613
let browserFailed = false;
500614

615+
// Track whether the flow reached the authorization redirect. Used to
616+
// distinguish discovery/registration failures (before this point) from
617+
// failures during user interaction or code exchange (after this point).
618+
let reachedAuthorization = false;
619+
501620
// Handler refs for cleanup
502621
const pasteHandlerRef: { current: { cleanup: () => void } | null } = { current: null };
503622

@@ -518,6 +637,10 @@ export async function performOAuthFlow(
518637

519638
// Override redirectToAuthorization to open browser
520639
provider.redirectToAuthorization = async (authorizationUrl: URL) => {
640+
// Reaching this point means discovery and client registration both
641+
// succeeded — any later failure is not a registration problem.
642+
reachedAuthorization = true;
643+
521644
// Inject a random `state` parameter if the SDK didn't add one. OAuth 2.1 treats
522645
// `state` as RECOMMENDED rather than REQUIRED, but RFC 6749 §4.1.1 allows servers
523646
// to require it, and some production MCP servers do (e.g. Ubersuggest). PKCE
@@ -614,7 +737,10 @@ export async function performOAuthFlow(
614737
};
615738
} catch (error) {
616739
logger.debug(`OAuth flow failed: ${(error as Error).message}`);
617-
throw error;
740+
throw explainOAuthRegistrationFailure(error, {
741+
serverUrl: normalizedServerUrl,
742+
reachedAuthorization,
743+
});
618744
} finally {
619745
// Clean up paste handler in case of error
620746
pasteHandlerRef.current?.cleanup();

test/unit/lib/auth/oauth-flow.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,131 @@
22
* Unit tests for OAuth flow utility functions
33
*/
44

5+
import {
6+
InvalidClientError,
7+
InvalidClientMetadataError,
8+
ServerError as SdkServerError,
9+
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
510
import { validateClientMetadataUrl } from '../../../../src/lib/auth/oauth-utils.js';
11+
import { explainOAuthRegistrationFailure } from '../../../../src/lib/auth/oauth-flow.js';
12+
import { AuthError } from '../../../../src/lib/errors.js';
13+
14+
describe('explainOAuthRegistrationFailure', () => {
15+
const serverUrl = 'https://mcp.figma.com/mcp';
16+
const preAuth = { serverUrl, reachedAuthorization: false };
17+
18+
it('rewrites the SDK non-JSON 403 error (Figma) into allow-list guidance', () => {
19+
// The exact error the MCP SDK surfaces for Figma's plain-text "Forbidden" body.
20+
const raw = new SdkServerError(
21+
"HTTP 403: Invalid OAuth error response: SyntaxError: Unexpected token 'F', " +
22+
'"Forbidden" is not valid JSON. Raw body: Forbidden'
23+
);
24+
25+
const result = explainOAuthRegistrationFailure(raw, preAuth);
26+
27+
expect(result).toBeInstanceOf(AuthError);
28+
const message = (result as AuthError).message;
29+
expect(message).toContain('mcp.figma.com refused to register mcpc');
30+
expect(message).toContain('(HTTP 403)');
31+
expect(message).toContain('--client-id');
32+
expect(message).toContain('--client-metadata-url');
33+
// The underlying server response stays visible (login prints only error.message).
34+
expect(message).toContain('Server response:');
35+
expect(message).toContain('Raw body: Forbidden');
36+
expect((result as AuthError).details).toEqual({ originalError: raw.message });
37+
});
38+
39+
it('recognizes a 401/403 registration rejection even when the error is untyped', () => {
40+
const raw = new Error('HTTP 401: Invalid OAuth error response: foo. Raw body: Unauthorized');
41+
42+
const result = explainOAuthRegistrationFailure(raw, preAuth);
43+
44+
expect(result).toBeInstanceOf(AuthError);
45+
expect((result as AuthError).message).toContain('refused to register mcpc');
46+
});
47+
48+
it('recognizes OAuth-coded client rejections without an HTTP status', () => {
49+
const raw = new InvalidClientError('mcpc is not an approved client');
50+
51+
const result = explainOAuthRegistrationFailure(raw, preAuth);
52+
53+
expect(result).toBeInstanceOf(AuthError);
54+
const message = (result as AuthError).message;
55+
expect(message).toContain('refused to register mcpc as an OAuth client.');
56+
expect(message).toContain('Server response: mcpc is not an approved client');
57+
});
58+
59+
it('explains servers that expose no registration endpoint', () => {
60+
const raw = new Error('Incompatible auth server: does not support dynamic client registration');
61+
62+
const result = explainOAuthRegistrationFailure(raw, preAuth);
63+
64+
expect(result).toBeInstanceOf(AuthError);
65+
const message = (result as AuthError).message;
66+
expect(message).toContain('does not support Dynamic Client Registration');
67+
expect(message).toContain('--client-id');
68+
});
69+
70+
it('does not claim an allow-list for a registration 5xx', () => {
71+
const raw = new SdkServerError(
72+
'HTTP 500: Invalid OAuth error response: SyntaxError: bad. Raw body: <html>outage</html>'
73+
);
74+
75+
const result = explainOAuthRegistrationFailure(raw, preAuth);
76+
77+
expect(result).toBeInstanceOf(AuthError);
78+
const message = (result as AuthError).message;
79+
expect(message).toContain('Client registration with mcp.figma.com failed:');
80+
expect(message).not.toContain('refused to register');
81+
expect(message).not.toContain('allow-list');
82+
expect(message).toContain('--client-id');
83+
});
84+
85+
it('reports rejected client metadata without claiming an allow-list', () => {
86+
const raw = new InvalidClientMetadataError('redirect_uri is not allowed');
87+
88+
const result = explainOAuthRegistrationFailure(raw, preAuth);
89+
90+
expect(result).toBeInstanceOf(AuthError);
91+
const message = (result as AuthError).message;
92+
expect(message).toContain('Client registration with mcp.figma.com failed:');
93+
expect(message).toContain('redirect_uri is not allowed');
94+
expect(message).not.toContain('refused to register');
95+
});
96+
97+
it('truncates oversized response bodies in the message but keeps details intact', () => {
98+
const body = 'x'.repeat(2000);
99+
const raw = new SdkServerError(
100+
`HTTP 403: Invalid OAuth error response: bad. Raw body: ${body}`
101+
);
102+
103+
const result = explainOAuthRegistrationFailure(raw, preAuth);
104+
105+
const message = (result as AuthError).message;
106+
expect(message).toContain('…');
107+
expect(message).not.toContain(body);
108+
expect((result as AuthError).details).toEqual({ originalError: raw.message });
109+
});
110+
111+
it('leaves the error unchanged once authorization has been reached', () => {
112+
// A 403 after the redirect is a token-exchange failure, not registration.
113+
const raw = new SdkServerError('HTTP 403: Invalid OAuth error response. Raw body: Forbidden');
114+
115+
const result = explainOAuthRegistrationFailure(raw, { serverUrl, reachedAuthorization: true });
116+
117+
expect(result).toBe(raw);
118+
});
119+
120+
it('leaves unrelated pre-authorization errors unchanged', () => {
121+
for (const raw of [
122+
new Error('connect ECONNREFUSED 127.0.0.1:3845'),
123+
new Error('Forbidden'), // no HTTP status, not an SDK OAuth error
124+
new Error('Authentication cancelled by user'),
125+
]) {
126+
expect(explainOAuthRegistrationFailure(raw, preAuth)).toBe(raw);
127+
}
128+
});
129+
});
6130

7131
describe('validateClientMetadataUrl', () => {
8132
it('accepts a valid HTTPS URL with path', () => {

0 commit comments

Comments
 (0)