Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-data-bearer-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge-ui": patch
---

Fix header-auth MCP connectors (e.g. Bright Data) rejecting pasted API keys with 401 because the required `Bearer ` scheme prefix wasn't applied. The Connect / Replace Key dialog now tests the pasted key live against the upstream server — as typed, then with a `Bearer ` prefix, then with a `Basic ` prefix — reporting each attempt in the dialog, and stores whichever one actually connects.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const ConnectorSettings = () => {
const [connectorAwaitingKey, setConnectorAwaitingKey] = useState<ConnectorCatalogEntry | null>(null);
const [selectedConnector, setSelectedConnector] = useState<ConnectorBase | null>(null);
const [apiKey, setApiKey] = useState('');
const [probeLog, setProbeLog] = useState<string[]>([]);

const connectorIconMap = useMemo(() => {
return (catalog ?? []).reduce(
Expand Down Expand Up @@ -141,6 +142,7 @@ const ConnectorSettings = () => {
const closeApiKeyModal = () => {
setConnectorAwaitingKey(null);
setApiKey('');
setProbeLog([]);
setFormError(null);
};

Expand All @@ -166,6 +168,7 @@ const ConnectorSettings = () => {
const handleConnect = (entry: ConnectorCatalogEntry) => {
if (entry.auth.type === 'header') {
setApiKey('');
setProbeLog([]);
setFormError(null);
setConnectorAwaitingKey(entry);
return;
Expand All @@ -176,38 +179,78 @@ const ConnectorSettings = () => {
}).catch(() => {});
};

/**
* A pasted key's required scheme (none, `Bearer `, `Basic `, …) isn't knowable up front, so try
* the key as typed first, then the common schemes, in order — deduping a candidate that's
* byte-identical to one already tried (e.g. the user already typed the `Bearer ` prefix).
*/
const buildAuthCandidates = (rawKey: string): { label: string; value: string }[] => {
const trimmed = rawKey.trim();
const candidates: { label: string; value: string }[] = [{ label: 'Testing key…', value: trimmed }];
if (!/^bearer\s/i.test(trimmed)) {
candidates.push({ label: 'Trying with prefix "Bearer"…', value: `Bearer ${trimmed}` });
}
if (!/^basic\s/i.test(trimmed)) {
candidates.push({ label: 'Trying with prefix "Basic"…', value: `Basic ${trimmed}` });
}
return candidates;
};

const handleApiKeySubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!connectorAwaitingKey || !apiKey.trim()) return;

const entry = connectorAwaitingKey;
const headerName = entry.auth.type === 'header' ? entry.auth.headerName : undefined;
const existing = connectors.ordered.find(({ connector }) => connector.id === entry.id);
const existingConnector = existing?.isConfigured ? existing.connector : undefined;

setFormError(null);
void runMutation(async () => {
const auth: ConnectorAuth = {
type: 'header',
apiKey: apiKey.trim(),
...(entry.auth.type === 'header' && entry.auth.headerName ? { headerName: entry.auth.headerName } : {}),
};
const existing = connectors.ordered.find(({ connector }) => connector.id === entry.id);
const existingConnector = existing?.isConfigured ? existing.connector : undefined;
if (existingConnector) {
await connectorCatalog.updateConnector({
id: existingConnector.id,
name: existingConnector.name,
description: existingConnector.description,
url: existingConnector.url,
auth,
});
} else {
await createFromCatalog(entry, auth);
setError(null);
setProbeLog([]);
setBusy(true);
void (async () => {
let lastError = 'Connection failed';
for (const candidate of buildAuthCandidates(apiKey)) {
setProbeLog(log => [...log, candidate.label]);
const auth: ConnectorAuth = {
type: 'header',
apiKey: candidate.value,
...(headerName ? { headerName } : {}),
};
try {
if (existingConnector) {
await connectorCatalog.updateConnector({
id: existingConnector.id,
name: existingConnector.name,
description: existingConnector.description,
url: existingConnector.url,
auth,
});
} else {
await createFromCatalog(entry, auth);
}
await connectorCatalog.getToolsByConnectorId({ id: entry.id });
setProbeLog(log => [...log.slice(0, -1), `${candidate.label} succeeded`]);
setBusy(false);
closeApiKeyModal();
await refresh();
setTimeout(() => {
toaster?.showSuccess({
title: `${entry.name} ${existingConnector ? 'updated' : 'connected'}`,
});
}, 100);
return;
} catch (err) {
lastError = getErrorMessage(err, 'Connection failed');
setProbeLog(log => [...log.slice(0, -1), `${candidate.label} failed`]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Probe retries cannot update new connectors

High Severity

existingConnector is captured once before the probe loop, so first-time Connect always calls createFromCatalog for every candidate. The raw key is persisted on the first attempt; later Bearer/Basic tries then hit a name conflict instead of updating. The working prefix is never stored, the dialog reports failure, and a connector remains saved with the rejected key.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4e5d13f. Configure here.

}
closeApiKeyModal();
setTimeout(() => {
toaster?.showSuccess({
title: `${entry.name} ${existingConnector ? 'updated' : 'connected'}`,
});
}, 100);
}, setFormError).catch(() => {});
setBusy(false);
setFormError(
`Could not connect with the provided key (tried as-is, with "Bearer", and with "Basic"). Last error: ${lastError}`,
);
})();
};

const handleAddMcpServer = async (draft: AddMcpServerDraft) => {
Expand Down Expand Up @@ -305,6 +348,7 @@ const ConnectorSettings = () => {
onClick={event => {
event.stopPropagation();
setApiKey('');
setProbeLog([]);
setConnectorAwaitingKey(connector);
}}
>
Expand Down Expand Up @@ -507,9 +551,15 @@ const ConnectorSettings = () => {
placeholder={`Paste the token from ${connectorAwaitingKey?.name ?? 'the provider'}`}
autoFocus
required
className={auiInputClass('h-11')}
disabled={busy}
className={auiInputClass('h-11 disabled:opacity-60')}
/>
</div>
{probeLog.length > 0 ? (
<pre className="whitespace-pre-wrap rounded-md border border-border bg-secondary-bg/40 px-3 py-2 font-mono text-xs text-text-secondary">
{probeLog.join('\n')}
</pre>
) : null}
{formError ? <p className="text-failure-bg text-sm">{formError}</p> : null}
</div>

Expand All @@ -518,7 +568,7 @@ const ConnectorSettings = () => {
Cancel
</Button>
<Button type="submit" disabled={!apiKey.trim() || busy}>
{isReplacingKey ? 'Replace Key' : 'Connect'}
{busy ? 'Testing…' : isReplacingKey ? 'Replace Key' : 'Connect'}
</Button>
</footer>
</form>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeAll, describe, expect, it } from 'vitest';

import ConnectorSettings from '@/containers/SettingsBuilder/ConnectorSettings.js';
import { ServerProvider } from '@/server/ServerContext.js';
import type { ConnectorBase, ConnectorCatalogEntry } from '@/server/types.js';
import { createMockAgentUIServer, createMockCatalog } from '../../server/mockServer.js';

beforeAll(() => {
HTMLDialogElement.prototype.showModal = function showModal() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close = function close() {
this.removeAttribute('open');
this.dispatchEvent(new Event('close'));
};
});

const brightData: ConnectorCatalogEntry = {
id: 'bright-data',
name: 'bright-data',
description: 'Search the web and scrape pages, including sites behind bot protection.',
url: 'https://mcp.brightdata.com/mcp',
auth: { type: 'header', headerName: 'Authorization' },
};

const connectedBrightData: ConnectorBase = {
id: 'bright-data',
name: 'bright-data',
description: brightData.description ?? '',
url: brightData.url,
auth: { type: 'header', headerName: 'Authorization' },
requiresAuth: false,
authenticated: true,
};

/** `succeedsWhen` decides, per attempt, whether the just-saved header value should test as reachable. */
function renderConnectorSettings(succeedsWhen: (headerValue: string) => boolean) {
let lastAuthValue: string | undefined;
const attempts: string[] = [];
const server = createMockAgentUIServer({
catalog: createMockCatalog({
connectorCatalog: {
getConnectorCatalog: async () => [brightData],
listConnectors: async () => [],
getConnector: async () => connectedBrightData,
getToolsByConnectorId: async () => {
if (lastAuthValue === undefined || !succeedsWhen(lastAuthValue)) {
throw new Error(`upstream returned 401 Unauthorized for "${lastAuthValue}"`);
}
return [];
},
createConnector: async req => {
const value = req.auth.type === 'header' ? (req.auth.apiKey ?? '') : '';
lastAuthValue = value;
attempts.push(value);
return connectedBrightData;
},
updateConnector: async req => {
const value = req.auth.type === 'header' ? (req.auth.apiKey ?? '') : '';
lastAuthValue = value;
attempts.push(value);
return connectedBrightData;
},
authenticateConnector: async () => ({ authorization_endpoint: '' }),
disconnectConnector: async () => connectedBrightData,
},
}),
});

render(
<ServerProvider server={server}>
<ConnectorSettings />
</ServerProvider>,
);

return { attempts };
}

async function openConnectModal() {
const row = await screen.findByText('bright-data');
const connectButton = within(row.closest('article') as HTMLElement).getByRole('button', { name: 'Connect' });
fireEvent.click(connectButton);
return within(await screen.findByRole('dialog'));
}

function submit(dialog: ReturnType<typeof within>, apiKey: string) {
fireEvent.change(dialog.getByLabelText('API key / token'), { target: { value: apiKey } });
fireEvent.click(dialog.getByRole('button', { name: /Connect|Testing/ }));
}

describe('ConnectorSettings auto-probing header auth (fixes #490)', () => {
it('stores the raw key when it works on the first try', async () => {
const { attempts } = renderConnectorSettings(value => value === 'abc123');
const dialog = await openConnectModal();
submit(dialog, 'abc123');

await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(attempts).toEqual(['abc123']);
});

it('falls back to a Bearer prefix when the raw key is rejected, logging each attempt', async () => {
const { attempts } = renderConnectorSettings(value => value === 'Bearer abc123');
const dialog = await openConnectModal();
submit(dialog, 'abc123');

await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(attempts).toEqual(['abc123', 'Bearer abc123']);
});

it('falls back to Basic when both the raw key and Bearer are rejected', async () => {
const { attempts } = renderConnectorSettings(value => value === 'Basic abc123');
const dialog = await openConnectModal();
submit(dialog, 'abc123');

await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(attempts).toEqual(['abc123', 'Bearer abc123', 'Basic abc123']);
});

it('shows a final error, keeps the dialog open, and logs every failed attempt when all candidates fail', async () => {
const { attempts } = renderConnectorSettings(() => false);
const dialog = await openConnectModal();
submit(dialog, 'abc123');

expect(await dialog.findByText(/Could not connect with the provided key/)).toBeInTheDocument();
expect(attempts).toEqual(['abc123', 'Bearer abc123', 'Basic abc123']);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(dialog.getByText(/Testing key… failed/)).toBeInTheDocument();
expect(dialog.getByText(/Trying with prefix "Bearer"… failed/)).toBeInTheDocument();
expect(dialog.getByText(/Trying with prefix "Basic"… failed/)).toBeInTheDocument();
});

it('does not double-prefix a key already typed with "Bearer "', async () => {
const { attempts } = renderConnectorSettings(value => value === 'Basic Bearer abc123');
const dialog = await openConnectModal();
submit(dialog, 'Bearer abc123');

await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
// No separate "Bearer Bearer abc123" attempt — the raw try already carried the prefix.
expect(attempts).toEqual(['Bearer abc123', 'Basic Bearer abc123']);
});
});