feat(ui): add Authorization URL, Token URL and Scopes fields for OAuth Delegate and True Passthrough MCP servers (#46941)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-16 14:48:42 -07:00 • committed by GitHub
parent 6530b329f4
commit 3637dd711f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 163 additions and 12 deletions

View file

@ -3,9 +3,11 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Input } from "@/components/ui/input";
import { MultiSelect } from "@/components/shared/MultiSelect";
import DcrBridgeToggle from "./DcrBridgeToggle";
import { MountedFormField } from "@/components/common_components/MountedFormField";
import { textControl } from "./mcpFieldRules";
import { tagsControl, textControl } from "./mcpFieldRules";
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
interface PassthroughOAuthFlow {
@ -69,6 +71,7 @@ export default function PassthroughAuthorizeSection({
const clientSecretPlaceholder = classUnchanged
? "Leave blank to keep the currently saved secret (if any)"
: "Leave blank for public clients / PKCE";
const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
const clientIdExtra = classUnchanged
? "Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)."
: "Switching the auth type discards the previously saved app; enter a client ID here or leave blank to use dynamic client registration.";
@ -113,6 +116,39 @@ export default function PassthroughAuthorizeSection({
/>
)}
</MountedFormField>
<MountedFormField
label={<span className="text-sm font-medium text-foreground">Authorization URL (optional)</span>}
name="authorization_url"
help="Set this when the upstream publishes no OAuth metadata, for example an internal server behind Microsoft Entra. Leave blank to discover it from the upstream."
>
{(control) => (
<Input
{...textControl(control)}
placeholder="https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize"
className={fieldClassName}
/>
)}
</MountedFormField>
<MountedFormField
label={<span className="text-sm font-medium text-foreground">Token URL (optional)</span>}
name="token_url"
help="Token endpoint of the same authorization server. Leave blank to discover it from the upstream."
>
{(control) => (
<Input
{...textControl(control)}
placeholder="https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"
className={fieldClassName}
/>
)}
</MountedFormField>
<MountedFormField
label={<span className="text-sm font-medium text-foreground">Scopes (optional)</span>}
name={["credentials", "scopes"]}
help="Scopes requested from the upstream when a user authorizes. Press enter or comma between scopes."
>
{(control) => <MultiSelect {...tagsControl(control)} placeholder="Add scopes" className="rounded-lg" />}
</MountedFormField>
<DcrBridgeToggle authType={authType} initialChecked={dcrBridgeInitialChecked} />
{isEditing && onRemoveStoredAppChange && (
<Label className="items-start leading-normal font-normal text-foreground">

View file

@ -201,6 +201,22 @@ describe("buildCreateServerPayload credentials", () => {
},
);
it.each([["true_passthrough"], ["oauth_delegate"]])(
"carries the admin-entered authorization URL, token URL and scopes for %s",
(authType) => {
const entraServer = {
auth_type: authType,
authorization_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
token_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
credentials: { client_id: "org-app", scopes: ["api://mcp/.default", "offline_access", ""] },
};
const payload = payloadOf(build(entraServer));
expect(payload.authorization_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize");
expect(payload.token_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/token");
expect(payload.credentials).toEqual({ client_id: "org-app", scopes: ["api://mcp/.default", "offline_access"] });
},
);
it("merges the DCR-minted client into an oauth2 payload", () => {
const payload = payloadOf(
build(

View file

@ -295,11 +295,6 @@ export const CASES: readonly DifferentialCase[] = [
ui: {},
},
{ label: "credentials absent entirely", values: { ...ROOT, auth_type: "oauth2" }, ui: {} },
{
label: "removeStoredApp forces an explicit-null app write",
values: { ...ROOT, auth_type: "true_passthrough", credentials: { ...CREDS } },
ui: { removeStoredApp: true, mcpServer: { ...SERVER, auth_type: "true_passthrough" } },
},
{
label: "removeStoredApp ignored outside a client-forwarded mode",
values: { ...ROOT, auth_type: "api_key", credentials: { ...CREDS } },

View file

@ -398,3 +398,61 @@ it.each([
expect(result.payload.credentials?.client_secret).toBe(useDcr ? null : "configured-secret");
expect(result.payload.credentials?.dcr_issuer).toBe(useDcr ? "https://new.example" : undefined);
});
it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
"keeps the endpoints typed for %s when the server was saved as oauth2",
(authType) => {
const authorizationUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize";
const tokenUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token";
const values: EditServerFormValues = {
auth_type: authType,
transport: "http",
authorization_url: authorizationUrl,
token_url: tokenUrl,
};
const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.OAUTH2 } };
const result = buildEditServerPayload(values, ui);
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
expect(result.payload.authorization_url).toBe(authorizationUrl);
expect(result.payload.token_url).toBe(tokenUrl);
expect(result.payload.issuer).toBeNull();
expect(result.payload.registration_url).toBeNull();
},
);
it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
"writes an explicit null when the admin removes every scope on a %s server",
(authType) => {
const values: EditServerFormValues = {
auth_type: authType,
transport: "http",
credentials: { client_id: "org-app", scopes: [""] },
};
const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: authType } };
const result = buildEditServerPayload(values, ui);
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
expect(result.payload.credentials).toEqual({ client_id: "org-app", scopes: null });
},
);
it.each([
[["read"], ["read"]],
[[""], null],
])("keeps the scope edit %j when the saved app is removed on a client-forwarded server", (typed, expected) => {
const values: EditServerFormValues = {
auth_type: AUTH_TYPE.TRUE_PASSTHROUGH,
transport: "http",
credentials: { client_id: "cid", client_secret: "csec", scopes: typed },
};
const ui: EditServerUiState = {
...baseUi,
removeStoredApp: true,
mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.TRUE_PASSTHROUGH },
};
const result = buildEditServerPayload(values, ui);
expect(result.kind).toBe("ok");
if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
expect(result.payload.credentials).toEqual({ scopes: expected, client_id: null, client_secret: null });
});

View file

@ -214,6 +214,21 @@ const buildCredentials = (credentialValues: unknown): Readonly<Record<string, un
return Object.fromEntries(kept);
};
const unmountedOauth2Endpoints = (authType: string | undefined) =>
isClientForwardedTokenMode(authType) ? {} : { authorization_url: null, token_url: null };
const scopesCleared = (credentialValues: unknown): boolean => {
if (!credentialValues || typeof credentialValues !== "object") return false;
const scopes: unknown = (credentialValues as { scopes?: unknown }).scopes;
return Array.isArray(scopes) && scopes.every((scope: unknown) => scope == null || scope === "");
};
const withClearedScopes = (
credentials: Readonly<Record<string, unknown>> | undefined,
credentialValues: unknown,
): Readonly<Record<string, unknown>> | undefined =>
scopesCleared(credentialValues) ? { ...(credentials ?? {}), scopes: null } : credentials;
interface CredentialsEntryInput {
readonly dcrClient?: McpDcrCredentials | null;
readonly oauthFlow?: string;
@ -239,7 +254,7 @@ const resolveCredentialsEntry = ({
return { credentials: { ...credentials, ...dcrClient } };
}
if (removeStoredApp && isClientForwardedTokenMode(authType)) {
return { credentials: { client_id: null, client_secret: null } };
return { credentials: { ...(credentials ?? {}), client_id: null, client_secret: null } };
}
if (includeCredentials && credentials && Object.keys(credentials).length > 0) {
return { credentials };
@ -334,7 +349,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
// Client-forwarded rows persist ONLY the declared app; strip any token material lingering in the
// form (e.g. from a prior oauth2 authorize this session) so it can never reach the row.
const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
? preservedAdminCredentials(credentialsPayload)
? withClearedScopes(preservedAdminCredentials(credentialsPayload), credentialValues)
: credentialsPayload;
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
@ -355,7 +370,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
stdio_config: undefined,
env_json: undefined,
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
? { issuer: null, authorization_url: null, token_url: null, registration_url: null }
? { issuer: null, ...unmountedOauth2Endpoints(restValues.auth_type), registration_url: null }
: {}),
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE

View file

@ -264,6 +264,8 @@ const CASES: readonly Case[] = [
...EXPECTED_BASE,
auth_type: "true_passthrough",
credentials: { client_id: "cid", client_secret: "csec" },
authorization_url: undefined,
token_url: undefined,
},
},
{
@ -273,6 +275,8 @@ const CASES: readonly Case[] = [
...EXPECTED_BASE,
auth_type: "oauth_delegate",
credentials: { client_id: "cid", client_secret: "csec" },
authorization_url: undefined,
token_url: undefined,
},
},
{
@ -283,7 +287,13 @@ const CASES: readonly Case[] = [
{
label: "dcr_bridge stays true for a client-forwarded mode",
server: { ...BASE, auth_type: "true_passthrough", dcr_bridge: true },
expected: { ...EXPECTED_BASE, auth_type: "true_passthrough", dcr_bridge: true },
expected: {
...EXPECTED_BASE,
auth_type: "true_passthrough",
dcr_bridge: true,
authorization_url: undefined,
token_url: undefined,
},
},
{
label: "an existing allowed_tools list enforces the allowlist and round-trips",

View file

@ -333,6 +333,14 @@ describe("preservedAdminCredentials vs preservedDeclaredAppCredentials", () => {
expect(preservedAdminCredentials(credentials)).toEqual({ client_id: "cid", upstream_resource: "auto" });
});
it("preserves admin-typed scopes and drops blank ones", () => {
expect(preservedAdminCredentials({ client_id: "cid", scopes: ["api://mcp/.default", ""] })).toEqual({
client_id: "cid",
scopes: ["api://mcp/.default"],
});
expect(preservedAdminCredentials({ client_id: "cid", scopes: [] })).toEqual({ client_id: "cid" });
});
// The two helpers answer different questions and must not be collapsed: "has the admin declared an
// OAuth app" gates the app-may-not-match-upstream warning, so a resource-only server must read as
// having no declared app.

View file

@ -181,10 +181,23 @@ export const preservedDeclaredAppCredentials = (
// Everything the admin typed into `credentials` and nothing minted: the declared app plus the config
// keys. This is what must survive the invalidation reset and what a client-forwarded row may persist,
// so dropping a key from here silently discards admin input on an unrelated edit.
const adminScopes = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const kept = value.filter((scope): scope is string => typeof scope === "string" && scope !== "");
return kept.length > 0 ? kept : undefined;
};
export const preservedAdminCredentials = (
credentials: Record<string, unknown> | null | undefined,
): Record<string, string> | undefined =>
pickStringCredentials(credentials, [...DECLARED_APP_CREDENTIAL_KEYS, ...ADMIN_CONFIG_CREDENTIAL_KEYS]);
): Record<string, string | string[]> | undefined => {
const strings = pickStringCredentials(credentials, [
...DECLARED_APP_CREDENTIAL_KEYS,
...ADMIN_CONFIG_CREDENTIAL_KEYS,
]);
const scopes = adminScopes(credentials?.scopes);
if (scopes === undefined) return strings;
return { ...(strings ?? {}), scopes };
};
// Drop minted token keys, keeping everything else (the declared app plus any non-token config).
export const withoutMintedTokenCredentials = (