fix(ui): preview tools with a staged interactive OAuth token in the edit form

For authorization_code the edit preview listed tools by server_id only, relying on the stored
per-user DB credential, so a token authorized in the edit session gave an empty preview until the
admin saved; the create form previews the identical state through the config-based preview
endpoint, which takes the token explicitly. The edit fetch now routes through that same endpoint
when a staged interactive token is held, built from the form values with the saved record as
fallback, and keeps the by-server_id listing for every other case
This commit is contained in:
Tin 2026-07-09 15:06:43 -07:00
parent b304620311
commit 71e0491d37
2 changed files with 77 additions and 1 deletions

View file

@ -10,6 +10,7 @@ vi.mock("../networking", () => ({
updateMCPServer: vi.fn(),
listMCPTools: vi.fn().mockResolvedValue({ tools: [], error: null }),
storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}),
testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }),
}));
vi.mock("../molecules/notifications_manager", () => ({
@ -511,6 +512,30 @@ describe("MCPServerEdit OAuth token invalidation", () => {
expect(mockRemoveToken).toHaveBeenCalledWith("oauth_server_1", undefined);
});
it("previews tools with a staged interactive OAuth token before it is saved", async () => {
// Regression: for authorization_code the fetch went by server_id only, relying on the stored DB
// credential, so a token authorized in this edit session gave an empty preview until the admin
// saved; the create form previews the identical state via the config-based preview endpoint.
mockOauth.tokenResponse = { access_token: "staged-obo-tok" };
renderOAuthEdit();
await waitFor(() => {
expect(vi.mocked(networking.testMCPToolsListRequest)).toHaveBeenCalledWith(
"access-token",
expect.objectContaining({ server_id: "oauth_server_1", url: "https://example.com/mcp" }),
"staged-obo-tok",
);
});
expect(networking.listMCPTools).not.toHaveBeenCalled();
// Previewing must stay stateless: the staged token is committed only by an explicit Save
// (storeMCPOAuthUserCredential for authorization_code, setToken for the client-forwarded modes).
expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled();
expect(mockSetToken).not.toHaveBeenCalled();
expect(networking.updateMCPServer).not.toHaveBeenCalled();
mockOauth.tokenResponse = null;
});
it("keeps the admin's in-flight endpoint edits when the token is invalidated", async () => {
// Regression: invalidation used to form.resetFields the endpoint fields; with the edit Form's
// initialValues that silently reverted an admin-corrected token_url back to the saved (wrong)

View file

@ -17,7 +17,7 @@ import {
getMcpOAuthMode,
oauth2FlowToFormValue,
} from "./types";
import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential } from "../networking";
import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential, testMCPToolsListRequest } from "../networking";
import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore";
import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils";
import MCPServerCostConfig from "./mcp_server_cost_config";
@ -421,6 +421,53 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
};
// A token authorized in this edit session for interactive OAuth (authorization_code) is only
// committed to the DB on save, so a plain by-server_id listing cannot use it and the preview would
// stay empty until the admin saves; the create form previews the identical state through the
// config-based preview endpoint, which takes the staged token explicitly. Returns false when there
// is no staged interactive token so fetchTools falls through to the by-server_id listing.
const previewWithStagedInteractiveToken = async (
isPassthrough: boolean,
isBrowserHeldTokenMode: boolean,
): Promise<boolean> => {
const stagedToken =
!isPassthrough && !isBrowserHeldTokenMode && getEffectiveAuthType() === AUTH_TYPE.OAUTH2
? oauthTokenResponse?.access_token
: undefined;
if (!stagedToken) {
return false;
}
setIsLoadingTools(true);
setToolsError(null);
try {
const values = form.getFieldsValue(true);
const rawTransport = values.transport || mcpServer.transport;
const previewConfig = {
server_id: mcpServer.server_id,
server_name: values.server_name || mcpServer.server_name || mcpServer.alias,
url: values.url || mcpServer.url,
transport: rawTransport === TRANSPORT.OPENAPI ? TRANSPORT.HTTP : rawTransport,
auth_type: AUTH_TYPE.OAUTH2,
authorization_url: values.authorization_url,
token_url: values.token_url,
registration_url: values.registration_url,
};
const toolsResponse = await testMCPToolsListRequest(accessToken, previewConfig, stagedToken);
if (toolsResponse.tools && !toolsResponse.error) {
setTools(toolsResponse.tools);
} else {
setTools([]);
setToolsError(toolsResponse.message || "Failed to load tools");
}
} catch (error) {
setTools([]);
setToolsError(error instanceof Error ? error.message : "Failed to load tools");
} finally {
setIsLoadingTools(false);
}
return true;
};
const fetchTools = async () => {
if (!accessToken || !mcpServer.server_id) return;
@ -436,6 +483,10 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream,
}) === "passthrough";
const isBrowserHeldTokenMode = isClientForwardedTokenMode(getEffectiveAuthType());
if (await previewWithStagedInteractiveToken(isPassthrough, isBrowserHeldTokenMode)) {
return;
}
if (isPassthrough || isBrowserHeldTokenMode) {
const token =
oauthTokenResponse?.access_token ??