Skip to content

fix(servers): complete MCP OAuth callback UX - #199

Open
vishu-bh wants to merge 1 commit into
mainfrom
fix/4684-mcp-oauth-callback-ux
Open

vishu-bh wants to merge 1 commit into
mainfrom
fix/4684-mcp-oauth-callback-ux

Conversation

@vishu-bh

Copy link
Copy Markdown
Contributor

Summary

OAuth authorization on the MCP Servers page now opens the server's Components view, shows success inline, and reloads its tools, resources, and prompts. Failures display the provider's reason in a dialog with retry; dismissal leaves an Authorization needed status and an adjacent authorization action when no valid token exists.

  • Keep authorization and component refresh independent, including refresh retries after successful authorization.
  • Preserve valid tokens after failed reauthorization, handle cancellation and duplicate clicks, and keep late callbacks scoped to their initiating server.
  • Add localized copy and keyboard/focus handling. Manual creation/editing and catalog OAuth flows retain their existing behavior.

Fixes IBM/mcp-context-forge#4684.

Validation

  • Full unit suite: 227 files passed; 3,805 tests passed and 1 skipped.
  • npm run build and npm run lint passed.
  • Formatting and git diff --check passed for the changes.
  • Four focused OAuth browser tests passed using installed Google Chrome, including failure → dismissal → retry → success with fresh components and the existing creation flow.
  • Standalone e2e type checking still reports errors in unchanged e2e/tools.spec.ts and src/utils/errors.ts.

Manual verification

On an existing OAuth authorization-code server, deny consent, dismiss the failure dialog, retry from Authorization needed, and complete authorization. Confirm inline success and freshly loaded components, then use Refresh components without reopening OAuth. Also verify Escape dismisses only the dialog and closing a pending popup produces no failure dialog.

@vishu-bh
vishu-bh marked this pull request as ready for review October 21, 2026 14:09
Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
@vishu-bh
vishu-bh force-pushed the fix/4684-mcp-oauth-callback-ux branch from ceb9a29 to 54fada7 Compare October 21, 2026 14:10

@a-effort a-effort left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change requests inline; lmk if you want to discuss any of these proposals. 🙌

{componentRefreshError && (
<InlineNotification
type="warning"
message={`${intl.formatMessage({ id: "mcpServer.oauth.discoveryError" })} ${componentRefreshError}`}

@a-effort a-effort Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Servers.tsx:311 already falls back to "Components could not be refreshed." when result.error is missing, so this line repeats the sentence. Store only the error reason; this line can add the sentence.

Comment thread src/pages/Servers.tsx
Comment on lines 308 to 316
if (result && result.success === false) {
setComponentRefreshErrors((prev) => ({
...prev,
[id]: result.error ?? intl.formatMessage({ id: "mcpServer.oauth.discoveryError" }),
}));
toast.error(intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), {
description: result.error ?? intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }),
});
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A panel refresh now shows both a toast here and the inline warning in the Components tab. To reduce noise: only use the inline warning next to the list (includes the retry action). Only use the toast for the table action.

Line 380 writes sanitizeError(err) to the inline error before the 409 check at line 381. On a conflict, the toast says "A refresh is already running for X," but the inline text shows a generic error and a Retry refresh button while the refresh is still running. Move the write below the 409 branch so a conflict shows only the toast. Retrying cannot resolve a conflict.

Comment on lines +680 to +694
{needsAuthorizationAfterFailure ? (
<>
<span className="text-warning">
{intl.formatMessage({ id: "mcpServer.oauth.needed" })}
</span>
{onAuthorize && (
<Button
variant="outline"
size="xs"
disabled={isAuthorizing}
onClick={() => void onAuthorize()}
>
{intl.formatMessage({ id: "mcpServer.oauth.authorize" })}
</Button>
)}

@a-effort a-effort Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: route this through ServerStatusIndicator instead. The bare span drops the status icon, the popover, and the ServerStatusDetail copy explaining that authorization is per person. The PR already adds authorizationNeeded and the table uses it, so the panel should too. #130 made this indicator the shared surface for server status and #160 is making its presentation reusable, so new status states belong inside it.

Comment on lines +54 to 61
const showAuthorizationNeeded = authorizationNeeded && needsOAuthAuthorization(availability);
const statusLabel = intl.formatMessage({
id: compact ? presentation.shortLabelId : presentation.labelId,
id: showAuthorizationNeeded
? "mcpServer.oauth.needed"
: compact
? presentation.shortLabelId
: presentation.labelId,
});

@a-effort a-effort Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the page needs a separate state after a failed attempt, add it to ServerAvailability and PRESENTATION so every view uses. Otherwise, use authorization_required.

-->
Details:

This prop lets a caller replace the shared labels. When a caller sets authorizationNeeded, the component skips labelId and shortLabelId from PRESENTATION in serverStatus.ts and uses a fixed key instead, so the page chooses the wording rather than the server status. ServersTable is the sole caller, so this adds page-specific logic to the shared component, which #160 removes.

The override also catches the wrong cases. needsOAuthAuthorization includes authorization_expired, so an expired token shows "Authorization needed" instead of "Authorization expired." It also skips the compact branch, so the table cell shows the full text instead of "Auth."

"mcpServer.oauth.discoveryError": "Components could not be refreshed.",
"mcpServer.oauth.retryRefresh": "Retry refresh",
"mcpServer.oauth.readError": "Tools, resources, or prompts could not be loaded.",
"mcpServer.oauth.needed": "Authorization needed"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop this and reuse mcpServer.status.authRequired ("Authorization required").

Comment thread src/pages/Servers.tsx
Comment on lines +29 to +31
function oauthFailureReason(error: unknown): string {
return error instanceof OAuthAuthorizationError ? error.message : sanitizeError(error);
}

@a-effort a-effort Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: add a length limit. This returns the provider's errorDescription unchanged, which bypasses sanitizeError. The popup handler in servers.ts checks event.source but not event.origin, so any page loaded last in the popup can set this string. React renders it as text, so it cannot inject code, but an attacker can still place text of any length in the dialog.

Comment on lines +285 to +286
const supportsAuthorization =
oauthStatus?.state === "ready" || server?.oauthConfig?.grant_type === "authorization_code";

@a-effort a-effort Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supportsAuthorization only confirms that the server supports authorization_code OAuth, so it passes when oauthStatus.state is "ready" with a valid token, and the oauthConfig fallback passes while the status loads. An active server then shows an authorize button at line 437. Use needsOAuthAuthorization, as ServerStatusIndicator does, so the button appears only when the server needs authorization.

Servers.tsx uses line 437 as the focus target for onCloseAutoFocus, so closing the failure dialog needs a different target when the token is valid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CHORE]: MCP server OAuth callback ux

2 participants