feat(ui): grey out client-forwarded and OBO MCP servers on the gateway connect flow

On the aggregate gateway connect flow the client holds only an identity-only session
bearer, and upstream credentials are resolved server-side from the per-user vault, which
is only populated by interactive authorization_code (oauth2). The client-forwarded modes
(true_passthrough, oauth_delegate) need the caller to present the upstream Authorization
per call, and oauth2_token_exchange (OBO) needs the caller's own IdP token as the exchange
subject; the session bearer is neither, so a tool call to those servers can never complete
on this connection.

Rather than let them look connectable and then 401, the grid greys those servers and
labels them "Not supported on this connection" when rendered in connect mode. Outside the
connect flow the normal integrations page is unchanged, since the client forwards its own
token there and those modes work. The classification lives in a shared
isUnsupportedOnGatewayConnect helper next to isClientForwardedTokenMode so the UI gate and
the auth-mode taxonomy cannot drift.
This commit is contained in:
Tin Chi Lo 2026-07-14 21:02:15 -07:00
parent 5ab7a855c3
commit 6c74e9b988
3 changed files with 42 additions and 2 deletions

View file

@ -13,7 +13,7 @@ import {
getMCPOAuthUserCredentialStatus,
listMCPTools,
} from "../networking";
import { AUTH_TYPE, MCPServer, MCPTool, handleTransport } from "../mcp_tools/types";
import { AUTH_TYPE, MCPServer, MCPTool, handleTransport, isUnsupportedOnGatewayConnect } from "../mcp_tools/types";
import MessageManager from "@/components/molecules/message_manager";
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
@ -242,6 +242,13 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
};
const renderConnectionIndicator = (server: MCPServer) => {
if (connectMode && isUnsupportedOnGatewayConnect(server.auth_type)) {
return (
<span className="text-[11px] text-muted-foreground shrink-0 whitespace-nowrap">
Not supported on this connection
</span>
);
}
if (server.auth_type === AUTH_TYPE.OAUTH2) {
if (oauthConnected.has(server.server_id)) {
return <CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />;
@ -507,6 +514,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const color = getAvatarColor(name);
const isLeftCol = idx % 2 === 0;
const count = toolCounts[name];
const unsupported = !!connectMode && isUnsupportedOnGatewayConnect(server.auth_type);
return (
<div
@ -514,7 +522,9 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
onClick={() => setDetailServer(server)}
className={`flex items-center gap-3 p-4 bg-card cursor-pointer transition-colors hover:bg-accent/30 min-w-0 ${
isLeftCol ? "border-r" : ""
} ${Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "border-b" : ""}`}
} ${Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "border-b" : ""} ${
unsupported ? "opacity-50" : ""
}`}
>
{server.mcp_info?.logo_url ? (
<img

View file

@ -13,6 +13,7 @@ import {
preservedDeclaredAppCredentials,
withoutMintedTokenCredentials,
credentialAuthClass,
isUnsupportedOnGatewayConnect,
} from "./types";
describe("getOAuthAuthorizationIdentity", () => {
@ -231,3 +232,23 @@ describe("credentialAuthClass", () => {
expect(credentialAuthClass(null)).toBeNull();
});
});
describe("isUnsupportedOnGatewayConnect", () => {
it("flags the modes that need a caller-supplied upstream token or subject", () => {
// client-forwarded: caller presents the upstream Authorization per call
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.TRUE_PASSTHROUGH)).toBe(true);
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH_DELEGATE)).toBe(true);
// OBO: caller's own IdP token is the exchange subject, which the session bearer is not
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE)).toBe(true);
});
it("does not flag modes the gateway can serve from server-side state or interactive vaulting", () => {
// interactive authorization_code is the one mode the connect grid vaults per user
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.OAUTH2)).toBe(false);
// server-configured credentials need no per-user connect
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.API_KEY)).toBe(false);
expect(isUnsupportedOnGatewayConnect(AUTH_TYPE.NONE)).toBe(false);
expect(isUnsupportedOnGatewayConnect(null)).toBe(false);
expect(isUnsupportedOnGatewayConnect(undefined)).toBe(false);
});
});

View file

@ -52,6 +52,15 @@ export const AUTH_TYPE = {
export const isClientForwardedTokenMode = (authType?: string | null): boolean =>
authType === AUTH_TYPE.TRUE_PASSTHROUGH || authType === AUTH_TYPE.OAUTH_DELEGATE;
// Auth modes that cannot be used through the gateway aggregate connect flow, where the client holds
// only an identity-only session bearer and upstream credentials are resolved server-side from the
// per-user vault. The vault is only populated by interactive authorization_code (oauth2). The
// client-forwarded modes need the caller to present the upstream Authorization per call, and
// oauth2_token_exchange (OBO) needs the caller's own IdP token as the subject to exchange; the
// session bearer is neither, so none of these can complete a tool call on this connection.
export const isUnsupportedOnGatewayConnect = (authType?: string | null): boolean =>
isClientForwardedTokenMode(authType) || authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
export const OAUTH_FLOW = {
INTERACTIVE: "interactive",
M2M: "m2m",