feat(ui): add the enterprise-managed authorization (ID-JAG) MCP form arm and auto-connected Apps grid state

This commit is contained in:
Tin Chi Lo 2026-07-21 15:51:31 -07:00
parent 58ff0e32ba
commit 19e9a31a52
9 changed files with 532 additions and 58 deletions

View file

@ -0,0 +1,157 @@
import React, { useState } from "react";
import { Form, Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
interface IdJagFormFieldsProps {
isEditing?: boolean;
}
const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
<span className="text-sm font-medium text-gray-700 flex items-center">
{label}
<Tooltip title={tooltip}>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
);
const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
const [clientAuthMethod, setClientAuthMethod] = useState<"client_secret" | "private_key_jwt">("client_secret");
return (
<>
<Form.Item
label={
<FieldLabel
label="IdP Token Exchange Endpoint"
tooltip="Your enterprise identity provider's token endpoint (the organization authorization server). The gateway exchanges the user's SSO identity assertion here for an ID-JAG (RFC 8693 with requested_token_type id-jag)."
/>
}
name="token_exchange_endpoint"
rules={[{ required: !isEditing, message: "The IdP token exchange endpoint is required for ID-JAG" }]}
>
<Input placeholder="https://your-org.okta.com/oauth2/v1/token" className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Resource Token Endpoint (optional)"
tooltip="The upstream MCP server's authorization server token endpoint, where the ID-JAG is redeemed for the access token (RFC 7523 jwt-bearer). Leave blank to auto-discover it from the upstream's protected-resource metadata (RFC 9728 then RFC 8414); discovery only trusts an authorization server that advertises the id-jag grant profile."
/>
}
name={["credentials", "id_jag_resource_token_endpoint"]}
>
<Input placeholder="https://mcp-as.example.com/oauth2/token" className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Client ID"
tooltip="The gateway's OAuth client ID. It must be registered at BOTH the identity provider and the upstream's authorization server, and the ID-JAG's client_id claim must match it."
/>
}
name={["credentials", "client_id"]}
rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
>
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Client Authentication"
tooltip="How the gateway authenticates to both token endpoints: a shared client secret, or a private key signing a JWT client assertion (private_key_jwt)."
/>
}
>
<Select<"client_secret" | "private_key_jwt">
className="rounded-lg"
size="large"
value={clientAuthMethod}
onChange={setClientAuthMethod}
options={[
{ value: "client_secret", label: <span className="font-medium">Client Secret</span> },
{ value: "private_key_jwt", label: <span className="font-medium">Private Key JWT</span> },
]}
/>
</Form.Item>
{clientAuthMethod === "client_secret" && (
<Form.Item
label={
<FieldLabel
label="Client Secret"
tooltip="OAuth2 client secret used to authenticate to both token endpoints."
/>
}
name={["credentials", "client_secret"]}
rules={[{ required: !isEditing, message: "Client Secret is required for ID-JAG" }]}
>
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
)}
{clientAuthMethod === "private_key_jwt" && (
<>
<Form.Item
label={
<FieldLabel
label="Client Private Key"
tooltip="PEM private key used to sign the JWT client assertion (private_key_jwt). Stored encrypted at rest."
/>
}
name={["credentials", "client_private_key"]}
rules={[{ required: !isEditing, message: "A private key is required for private_key_jwt" }]}
>
<Input.TextArea
rows={4}
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
className={fieldClassName}
/>
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Key ID (optional)"
tooltip="The kid header for the client assertion, when the authorization server needs one to select the verification key."
/>
}
name={["credentials", "client_private_key_id"]}
>
<Input placeholder="key-2026-01" className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Signing Algorithm (optional)"
tooltip="JWS algorithm for the client assertion. Defaults to RS256."
/>
}
name={["credentials", "client_assertion_signing_alg"]}
>
<Input placeholder="RS256" className={fieldClassName} />
</Form.Item>
</>
)}
<Form.Item
label={
<FieldLabel
label="Audience (optional)"
tooltip="The upstream authorization server's identifier, sent as the RFC 8693 audience so the IdP mints the ID-JAG for it."
/>
}
name="audience"
>
<Input placeholder="https://mcp-as.example.com" className={fieldClassName} />
</Form.Item>
<Form.Item
label={<FieldLabel label="Scopes (optional)" tooltip="Scopes requested in the ID-JAG exchange." />}
name={["credentials", "scopes"]}
>
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
</Form.Item>
</>
);
};
export default IdJagFormFields;

View file

@ -1058,6 +1058,128 @@ describe("CreateMCPServer", () => {
});
});
it("routes Enterprise-Managed Authorization (ID-JAG) config to the backend payload", async () => {
await selectHttpTransport();
fireEvent.change(getServerNameInput(), { target: { value: "EMA_Server" } });
fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), {
target: { value: "https://upstream.example.com/mcp" },
});
await selectAntOption("Authentication", "Enterprise-Managed Authorization (ID-JAG)");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token"), {
target: { value: "https://org.example.com/oauth2/v1/token" },
});
fireEvent.change(screen.getByPlaceholderText("https://mcp-as.example.com/oauth2/token"), {
target: { value: "https://ras.example.com/oauth2/token" },
});
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), {
target: { value: "ema-client-id" },
});
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), {
target: { value: "ema-client-secret" },
});
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-ema",
server_name: "EMA_Server",
alias: "EMA_Server",
url: "https://upstream.example.com/mcp",
transport: "http",
auth_type: "oauth2_id_jag",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("oauth2_id_jag");
expect(payload.token_exchange_endpoint).toBe("https://org.example.com/oauth2/v1/token");
expect(payload.credentials).toMatchObject({
client_id: "ema-client-id",
client_secret: "ema-client-secret",
id_jag_resource_token_endpoint: "https://ras.example.com/oauth2/token",
});
});
it("routes ID-JAG private_key_jwt client auth into nested credentials", async () => {
await selectHttpTransport();
fireEvent.change(getServerNameInput(), { target: { value: "EMA_PK_Server" } });
fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), {
target: { value: "https://upstream.example.com/mcp" },
});
await selectAntOption("Authentication", "Enterprise-Managed Authorization (ID-JAG)");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
});
fireEvent.change(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token"), {
target: { value: "https://org.example.com/oauth2/v1/token" },
});
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), {
target: { value: "ema-client-id" },
});
await selectAntOption("Client Authentication", "Private Key JWT");
await waitFor(() => {
expect(screen.getByPlaceholderText("-----BEGIN PRIVATE KEY-----")).toBeInTheDocument();
});
expect(screen.queryByPlaceholderText("Enter OAuth client secret")).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("-----BEGIN PRIVATE KEY-----"), {
target: { value: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" },
});
fireEvent.change(screen.getByPlaceholderText("key-2026-01"), {
target: { value: "kid-1" },
});
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-ema-pk",
server_name: "EMA_PK_Server",
alias: "EMA_PK_Server",
url: "https://upstream.example.com/mcp",
transport: "http",
auth_type: "oauth2_id_jag",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("oauth2_id_jag");
expect(payload.credentials).toMatchObject({
client_id: "ema-client-id",
client_private_key: expect.stringContaining("BEGIN PRIVATE KEY"),
client_private_key_id: "kid-1",
});
expect(payload.credentials.client_secret).toBeUndefined();
});
it("makes scope required when the Entra OBO profile is selected", async () => {
await selectHttpTransport();

View file

@ -25,6 +25,7 @@ import OAuthFormFields from "./OAuthFormFields";
import TruePassthroughWarning from "./TruePassthroughWarning";
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
import TokenExchangeFormFields from "./TokenExchangeFormFields";
import IdJagFormFields from "./IdJagFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
import MCPToolConfiguration from "./mcp_tool_configuration";
@ -61,6 +62,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.OAUTH2_ID_JAG,
AUTH_TYPE.AWS_SIGV4,
AUTH_TYPE.TRUE_PASSTHROUGH,
AUTH_TYPE.OAUTH_DELEGATE,
@ -140,6 +142,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
@ -1071,7 +1074,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
children: (
<>
<Form.Item name="auth_type" rules={[{ required: true, message: "Please select an auth type" }]}>
<Select placeholder="Select auth type" className="rounded-lg" size="large">
<Select placeholder="Select auth type" className="rounded-lg" size="large" virtual={false}>
<Select.Option value="none">None</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
@ -1079,6 +1082,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
<Select.Option value="oauth2_id_jag">
Enterprise-Managed Authorization (ID-JAG)
</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
<Select.Option value="oauth_delegate">
@ -1144,6 +1150,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
)}
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
{isIdJagAuthType && <IdJagFormFields />}
</>
),
},

View file

@ -393,6 +393,50 @@ describe("MCPServerEdit (auth type switch)", () => {
vi.clearAllMocks();
});
it("renders the ID-JAG arm on edit and nulls its shared fields when switching away", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({
...interactiveOAuthServer,
auth_type: "none",
});
render(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
auth_type: "oauth2_id_jag",
token_exchange_endpoint: "https://org.example.com/oauth2/v1/token",
audience: "https://ras.example.com",
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await waitFor(() => {
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
});
expect(screen.getByPlaceholderText("https://mcp-as.example.com/oauth2/token")).toBeInTheDocument();
await selectAntOption("Authentication", "None");
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
await act(async () => {
fireEvent.click(saveButtons[0]);
});
await waitFor(() => {
expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("none");
expect(payload.token_exchange_endpoint).toBeNull();
expect(payload.audience).toBeNull();
expect(payload.subject_token_type).toBeNull();
});
it("clears stale oauth2 endpoint overrides when switching to token exchange", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({
...interactiveOAuthServer,

View file

@ -34,6 +34,7 @@ import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import TokenExchangeFormFields from "./TokenExchangeFormFields";
import IdJagFormFields from "./IdJagFormFields";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
@ -62,6 +63,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.OAUTH2_ID_JAG,
AUTH_TYPE.AWS_SIGV4,
AUTH_TYPE.TRUE_PASSTHROUGH,
AUTH_TYPE.OAUTH_DELEGATE,
@ -101,6 +103,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
@ -854,6 +857,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
? { token_exchange_endpoint: null, audience: null, subject_token_type: null, token_exchange_profile: null }
: {}),
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_ID_JAG && restValues.auth_type !== AUTH_TYPE.OAUTH2_ID_JAG
? { token_exchange_endpoint: null, audience: null, subject_token_type: null }
: {}),
server_id: mcpServer.server_id,
mcp_info: {
...(mcpServer.mcp_info ?? {}),
@ -1106,7 +1112,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
{!isStdioTransport && (
<>
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
<Select>
<Select virtual={false}>
<Select.Option value="none">None</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="bearer_token">Bearer Token</Select.Option>
@ -1114,6 +1120,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
<Select.Option value="oauth2_id_jag">Enterprise-Managed Authorization (ID-JAG)</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
<Select.Option value="oauth_delegate">
@ -1443,6 +1450,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
)}
{!isStdioTransport && isTokenExchangeAuthType && <TokenExchangeFormFields isEditing />}
{!isStdioTransport && isIdJagAuthType && <IdJagFormFields isEditing />}
{!isStdioTransport && isAwsSigV4AuthType && (
<>

View file

@ -0,0 +1,93 @@
import React from "react";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MCPAppsPanel from "./MCPAppsPanel";
import * as networking from "../networking";
vi.mock("../networking", () => ({
fetchMCPServers: vi.fn(),
getMCPOAuthUserCredentialStatus: vi.fn(),
deleteMCPOAuthUserCredential: vi.fn(),
listMCPTools: vi.fn(),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
}));
const idJagServer = {
server_id: "srv-ema",
server_name: "ema_upstream",
alias: "ema_upstream",
url: "https://up.example.com/mcp",
transport: "http",
auth_type: "oauth2_id_jag",
created_at: "2024-01-01T00:00:00Z",
created_by: "u",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "u",
};
const plainServer = {
...idJagServer,
server_id: "srv-plain",
server_name: "plain_upstream",
alias: "plain_upstream",
auth_type: "none",
};
const renderPanel = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<MCPAppsPanel accessToken="sk-test" selectedServers={[]} onChange={() => {}} />
</QueryClientProvider>,
);
};
describe("MCPAppsPanel enterprise-managed authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.fetchMCPServers).mockResolvedValue([idJagServer, plainServer] as never);
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: [] } as never);
});
it("renders an id_jag server as already connected and never fetches its credential status", async () => {
const { container } = renderPanel();
await waitFor(() => {
expect(screen.getByText("ema_upstream")).toBeInTheDocument();
});
const tile = screen.getByText("ema_upstream").closest("div[class*='cursor-pointer']") ?? container;
expect(tile.querySelector("svg.text-emerald-600")).toBeTruthy();
expect(networking.getMCPOAuthUserCredentialStatus).not.toHaveBeenCalled();
});
it("shows the connected badge and no connect affordance in the id_jag detail pane", async () => {
renderPanel();
await waitFor(() => {
expect(screen.getByText("ema_upstream")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("ema_upstream"));
await waitFor(() => {
expect(screen.getByText("Connected via your organization sign-in")).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: /^Connect$/ })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Disconnect$/ })).not.toBeInTheDocument();
});
it("keeps the plain Connect toggle for a non id_jag server", async () => {
renderPanel();
await waitFor(() => {
expect(screen.getByText("plain_upstream")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("plain_upstream"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /^Connect$/ })).toBeInTheDocument();
});
expect(screen.queryByText("Connected via your organization sign-in")).not.toBeInTheDocument();
});
});

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, isAutoConnectedAuthType } from "../mcp_tools/types";
import MessageManager from "@/components/molecules/message_manager";
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
@ -294,48 +294,55 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
<h2 className="m-0 mb-1 text-[22px] font-bold text-foreground">{name}</h2>
<p className="m-0 text-sm text-muted-foreground">{detailServer.description ?? "MCP server"}</p>
</div>
{detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(detailServer.server_id) ? (
{isAutoConnectedAuthType(detailServer.auth_type) && (
<span className="inline-flex items-center gap-1.5 h-[38px] px-3 rounded-md bg-emerald-50 text-emerald-700 text-sm font-semibold">
<CheckCircle className="h-4 w-4" />
Connected via your organization sign-in
</span>
)}
{!isAutoConnectedAuthType(detailServer.auth_type) &&
(detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(detailServer.server_id) ? (
<Button
variant="destructive"
onClick={async () => {
try {
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
} catch (_) {
// Ignore
}
setOauthConnected((prev) => {
const n = new Set(prev);
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
className="font-semibold h-[38px] min-w-[110px]"
>
Disconnect
</Button>
) : (
<OAuth2ConnectButton
server={detailServer}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="button"
/>
)
) : (
<Button
variant="destructive"
onClick={async () => {
try {
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
} catch (_) {
// Ignore
}
setOauthConnected((prev) => {
const n = new Set(prev);
n.delete(detailServer.server_id);
return n;
});
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
}}
variant={isConnected ? "outline" : "default"}
disabled={isTogglingOn}
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
className="font-semibold h-[38px] min-w-[110px]"
>
Disconnect
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{isConnected ? "Disconnect" : "Connect"}
</Button>
) : (
<OAuth2ConnectButton
server={detailServer}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="button"
/>
)
) : (
<Button
variant={isConnected ? "outline" : "default"}
disabled={isTogglingOn}
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
className="font-semibold h-[38px] min-w-[110px]"
>
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{isConnected ? "Disconnect" : "Connect"}
</Button>
)}
))}
</div>
<h3 className="m-0 mb-3 text-[15px] font-semibold text-foreground">Information</h3>
@ -513,22 +520,32 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
) : null}
</div>
</div>
{server.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(server.server_id) ? (
<CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
) : (
<OAuth2ConnectButton
server={server}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="badge"
/>
)
) : isConnected ? (
<span className="w-[7px] h-[7px] rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0" />
) : null}
{(() => {
if (isAutoConnectedAuthType(server.auth_type)) {
return <CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />;
}
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" />;
}
return (
<OAuth2ConnectButton
server={server}
accessToken={accessToken}
onConnect={(id) => {
setOauthConnected((prev) => new Set(prev).add(id));
}}
variant="badge"
/>
);
}
if (isConnected) {
return (
<span className="w-[7px] h-[7px] rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0" />
);
}
return null;
})()}
<ChevronRight className="h-3 w-3 text-muted-foreground/40 shrink-0" />
</div>
);

View file

@ -231,3 +231,19 @@ describe("credentialAuthClass", () => {
expect(credentialAuthClass(null)).toBeNull();
});
});
describe("id_jag auth type", () => {
it("classifies oauth2_id_jag as the id_jag oauth mode", async () => {
const { getMcpOAuthMode, AUTH_TYPE } = await import("./types");
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2_ID_JAG })).toBe("id_jag");
});
it("auto-connects only oauth2_id_jag servers in the Apps grid", async () => {
const { isAutoConnectedAuthType, AUTH_TYPE } = await import("./types");
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2_ID_JAG)).toBe(true);
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2)).toBe(false);
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE)).toBe(false);
expect(isAutoConnectedAuthType(null)).toBe(false);
expect(isAutoConnectedAuthType(undefined)).toBe(false);
});
});

View file

@ -40,6 +40,7 @@ export const AUTH_TYPE = {
BASIC: "basic",
OAUTH2: "oauth2",
OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange",
OAUTH2_ID_JAG: "oauth2_id_jag",
AWS_SIGV4: "aws_sigv4",
TRUE_PASSTHROUGH: "true_passthrough",
OAUTH_DELEGATE: "oauth_delegate",
@ -161,7 +162,14 @@ export const MCP_OAUTH2_FLOW_M2M = "client_credentials";
export const MCP_OAUTH2_FLOW_INTERACTIVE = "authorization_code";
export type McpOAuthMode = "m2m" | "passthrough" | "authorization_code" | "token_exchange";
export type McpOAuthMode = "m2m" | "passthrough" | "authorization_code" | "token_exchange" | "id_jag";
/** Enterprise-managed authorization: the user's one SSO login is the only interaction, tokens
* mint via back-channel exchange, so the Apps grid renders these servers as already connected
* with no connect affordance. */
export function isAutoConnectedAuthType(authType?: string | null): boolean {
return authType === AUTH_TYPE.OAUTH2_ID_JAG;
}
// Classify an OAuth MCP server into the mode that decides how the tool list is
// authenticated. token_exchange (RFC 8693 / OBO) is its own auth_type
@ -180,6 +188,7 @@ export function getMcpOAuthMode(s: {
delegate_auth_to_upstream?: boolean | null;
}): McpOAuthMode | null {
if (s.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) return "token_exchange";
if (s.auth_type === AUTH_TYPE.OAUTH2_ID_JAG) return "id_jag";
if (s.auth_type !== AUTH_TYPE.OAUTH2) return null;
if (s.oauth2_flow === MCP_OAUTH2_FLOW_M2M) return "m2m";
return s.delegate_auth_to_upstream ? "passthrough" : "authorization_code";