diff --git a/.changeset/bright-data-bearer-prefix.md b/.changeset/bright-data-bearer-prefix.md new file mode 100644 index 000000000..cac7dad77 --- /dev/null +++ b/.changeset/bright-data-bearer-prefix.md @@ -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. diff --git a/packages/trueforge-ui/src/containers/SettingsBuilder/ConnectorSettings.tsx b/packages/trueforge-ui/src/containers/SettingsBuilder/ConnectorSettings.tsx index 9bd2529c4..1fbba1687 100644 --- a/packages/trueforge-ui/src/containers/SettingsBuilder/ConnectorSettings.tsx +++ b/packages/trueforge-ui/src/containers/SettingsBuilder/ConnectorSettings.tsx @@ -44,6 +44,7 @@ const ConnectorSettings = () => { const [connectorAwaitingKey, setConnectorAwaitingKey] = useState(null); const [selectedConnector, setSelectedConnector] = useState(null); const [apiKey, setApiKey] = useState(''); + const [probeLog, setProbeLog] = useState([]); const connectorIconMap = useMemo(() => { return (catalog ?? []).reduce( @@ -141,6 +142,7 @@ const ConnectorSettings = () => { const closeApiKeyModal = () => { setConnectorAwaitingKey(null); setApiKey(''); + setProbeLog([]); setFormError(null); }; @@ -166,6 +168,7 @@ const ConnectorSettings = () => { const handleConnect = (entry: ConnectorCatalogEntry) => { if (entry.auth.type === 'header') { setApiKey(''); + setProbeLog([]); setFormError(null); setConnectorAwaitingKey(entry); return; @@ -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) => { 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`]); + } } - 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) => { @@ -305,6 +348,7 @@ const ConnectorSettings = () => { onClick={event => { event.stopPropagation(); setApiKey(''); + setProbeLog([]); setConnectorAwaitingKey(connector); }} > @@ -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')} /> + {probeLog.length > 0 ? ( +
+                    {probeLog.join('\n')}
+                  
+ ) : null} {formError ?

{formError}

: null} @@ -518,7 +568,7 @@ const ConnectorSettings = () => { Cancel diff --git a/packages/trueforge-ui/test/containers/SettingsBuilder/ConnectorSettings.test.tsx b/packages/trueforge-ui/test/containers/SettingsBuilder/ConnectorSettings.test.tsx new file mode 100644 index 000000000..d61e73955 --- /dev/null +++ b/packages/trueforge-ui/test/containers/SettingsBuilder/ConnectorSettings.test.tsx @@ -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( + + + , + ); + + 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, 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']); + }); +});