From 4e5d13f8e6029de9e6133fac85951ec677d137bc Mon Sep 17 00:00:00 2001 From: Chris McKenzie Date: Sat, 29 Aug 2026 14:26:27 -0700 Subject: [PATCH] fix(connectors): auto-detect the scheme a header-auth key needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bright Data (and any other header-auth MCP connector — GitHub, Tavily, or a custom one) rejected pasted API keys with 401 because nothing told the user their key needs a scheme prefix like "Bearer ". Rather than asking the user to know this up front, the Connect / Replace Key dialog now tests the pasted key live against the real upstream server: as typed first, then with "Bearer ", then with "Basic " (skipping a scheme the raw key already carries), reporting each attempt inline ("Testing key… failed", "Trying with prefix "Bearer"… succeeded", …). Whichever candidate actually connects is what gets stored; if all three fail, the dialog stays open with the last real error. Minimal, single-file change (plus its test) — no new types, no catalog lookups, no server-side change. Reuses the existing create/update + getToolsByConnectorId calls the UI already had. Fixes #490 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MxyEPwQ73B27NTr83U69Ba --- .changeset/bright-data-bearer-prefix.md | 5 + .../SettingsBuilder/ConnectorSettings.tsx | 104 +++++++++---- .../ConnectorSettings.test.tsx | 143 ++++++++++++++++++ 3 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 .changeset/bright-data-bearer-prefix.md create mode 100644 packages/trueforge-ui/test/containers/SettingsBuilder/ConnectorSettings.test.tsx 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']); + }); +});