Skip to content

Commit 4e5d13f

Browse files
kristopolousclaude
andcommitted
fix(connectors): auto-detect the scheme a header-auth key needs
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxyEPwQ73B27NTr83U69Ba
1 parent a3a1395 commit 4e5d13f

3 files changed

Lines changed: 225 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@truefoundry/trueforge-ui": patch
3+
---
4+
5+
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.

packages/trueforge-ui/src/containers/SettingsBuilder/ConnectorSettings.tsx

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const ConnectorSettings = () => {
4444
const [connectorAwaitingKey, setConnectorAwaitingKey] = useState<ConnectorCatalogEntry | null>(null);
4545
const [selectedConnector, setSelectedConnector] = useState<ConnectorBase | null>(null);
4646
const [apiKey, setApiKey] = useState('');
47+
const [probeLog, setProbeLog] = useState<string[]>([]);
4748

4849
const connectorIconMap = useMemo(() => {
4950
return (catalog ?? []).reduce(
@@ -141,6 +142,7 @@ const ConnectorSettings = () => {
141142
const closeApiKeyModal = () => {
142143
setConnectorAwaitingKey(null);
143144
setApiKey('');
145+
setProbeLog([]);
144146
setFormError(null);
145147
};
146148

@@ -166,6 +168,7 @@ const ConnectorSettings = () => {
166168
const handleConnect = (entry: ConnectorCatalogEntry) => {
167169
if (entry.auth.type === 'header') {
168170
setApiKey('');
171+
setProbeLog([]);
169172
setFormError(null);
170173
setConnectorAwaitingKey(entry);
171174
return;
@@ -176,38 +179,78 @@ const ConnectorSettings = () => {
176179
}).catch(() => {});
177180
};
178181

182+
/**
183+
* A pasted key's required scheme (none, `Bearer `, `Basic `, …) isn't knowable up front, so try
184+
* the key as typed first, then the common schemes, in order — deduping a candidate that's
185+
* byte-identical to one already tried (e.g. the user already typed the `Bearer ` prefix).
186+
*/
187+
const buildAuthCandidates = (rawKey: string): { label: string; value: string }[] => {
188+
const trimmed = rawKey.trim();
189+
const candidates: { label: string; value: string }[] = [{ label: 'Testing key…', value: trimmed }];
190+
if (!/^bearer\s/i.test(trimmed)) {
191+
candidates.push({ label: 'Trying with prefix "Bearer"…', value: `Bearer ${trimmed}` });
192+
}
193+
if (!/^basic\s/i.test(trimmed)) {
194+
candidates.push({ label: 'Trying with prefix "Basic"…', value: `Basic ${trimmed}` });
195+
}
196+
return candidates;
197+
};
198+
179199
const handleApiKeySubmit = (event: FormEvent<HTMLFormElement>) => {
180200
event.preventDefault();
181201
if (!connectorAwaitingKey || !apiKey.trim()) return;
182202

183203
const entry = connectorAwaitingKey;
204+
const headerName = entry.auth.type === 'header' ? entry.auth.headerName : undefined;
205+
const existing = connectors.ordered.find(({ connector }) => connector.id === entry.id);
206+
const existingConnector = existing?.isConfigured ? existing.connector : undefined;
207+
184208
setFormError(null);
185-
void runMutation(async () => {
186-
const auth: ConnectorAuth = {
187-
type: 'header',
188-
apiKey: apiKey.trim(),
189-
...(entry.auth.type === 'header' && entry.auth.headerName ? { headerName: entry.auth.headerName } : {}),
190-
};
191-
const existing = connectors.ordered.find(({ connector }) => connector.id === entry.id);
192-
const existingConnector = existing?.isConfigured ? existing.connector : undefined;
193-
if (existingConnector) {
194-
await connectorCatalog.updateConnector({
195-
id: existingConnector.id,
196-
name: existingConnector.name,
197-
description: existingConnector.description,
198-
url: existingConnector.url,
199-
auth,
200-
});
201-
} else {
202-
await createFromCatalog(entry, auth);
209+
setError(null);
210+
setProbeLog([]);
211+
setBusy(true);
212+
void (async () => {
213+
let lastError = 'Connection failed';
214+
for (const candidate of buildAuthCandidates(apiKey)) {
215+
setProbeLog(log => [...log, candidate.label]);
216+
const auth: ConnectorAuth = {
217+
type: 'header',
218+
apiKey: candidate.value,
219+
...(headerName ? { headerName } : {}),
220+
};
221+
try {
222+
if (existingConnector) {
223+
await connectorCatalog.updateConnector({
224+
id: existingConnector.id,
225+
name: existingConnector.name,
226+
description: existingConnector.description,
227+
url: existingConnector.url,
228+
auth,
229+
});
230+
} else {
231+
await createFromCatalog(entry, auth);
232+
}
233+
await connectorCatalog.getToolsByConnectorId({ id: entry.id });
234+
setProbeLog(log => [...log.slice(0, -1), `${candidate.label} succeeded`]);
235+
setBusy(false);
236+
closeApiKeyModal();
237+
await refresh();
238+
setTimeout(() => {
239+
toaster?.showSuccess({
240+
title: `${entry.name} ${existingConnector ? 'updated' : 'connected'}`,
241+
});
242+
}, 100);
243+
return;
244+
} catch (err) {
245+
lastError = getErrorMessage(err, 'Connection failed');
246+
setProbeLog(log => [...log.slice(0, -1), `${candidate.label} failed`]);
247+
}
203248
}
204-
closeApiKeyModal();
205-
setTimeout(() => {
206-
toaster?.showSuccess({
207-
title: `${entry.name} ${existingConnector ? 'updated' : 'connected'}`,
208-
});
209-
}, 100);
210-
}, setFormError).catch(() => {});
249+
setBusy(false);
250+
setFormError(
251+
`Could not connect with the provided key (tried as-is, with "Bearer", and with "Basic"). Last error: ${lastError}`,
252+
);
253+
})();
211254
};
212255

213256
const handleAddMcpServer = async (draft: AddMcpServerDraft) => {
@@ -305,6 +348,7 @@ const ConnectorSettings = () => {
305348
onClick={event => {
306349
event.stopPropagation();
307350
setApiKey('');
351+
setProbeLog([]);
308352
setConnectorAwaitingKey(connector);
309353
}}
310354
>
@@ -507,9 +551,15 @@ const ConnectorSettings = () => {
507551
placeholder={`Paste the token from ${connectorAwaitingKey?.name ?? 'the provider'}`}
508552
autoFocus
509553
required
510-
className={auiInputClass('h-11')}
554+
disabled={busy}
555+
className={auiInputClass('h-11 disabled:opacity-60')}
511556
/>
512557
</div>
558+
{probeLog.length > 0 ? (
559+
<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">
560+
{probeLog.join('\n')}
561+
</pre>
562+
) : null}
513563
{formError ? <p className="text-failure-bg text-sm">{formError}</p> : null}
514564
</div>
515565

@@ -518,7 +568,7 @@ const ConnectorSettings = () => {
518568
Cancel
519569
</Button>
520570
<Button type="submit" disabled={!apiKey.trim() || busy}>
521-
{isReplacingKey ? 'Replace Key' : 'Connect'}
571+
{busy ? 'Testing…' : isReplacingKey ? 'Replace Key' : 'Connect'}
522572
</Button>
523573
</footer>
524574
</form>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// @vitest-environment jsdom
2+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
3+
import { beforeAll, describe, expect, it } from 'vitest';
4+
5+
import ConnectorSettings from '@/containers/SettingsBuilder/ConnectorSettings.js';
6+
import { ServerProvider } from '@/server/ServerContext.js';
7+
import type { ConnectorBase, ConnectorCatalogEntry } from '@/server/types.js';
8+
import { createMockAgentUIServer, createMockCatalog } from '../../server/mockServer.js';
9+
10+
beforeAll(() => {
11+
HTMLDialogElement.prototype.showModal = function showModal() {
12+
this.setAttribute('open', '');
13+
};
14+
HTMLDialogElement.prototype.close = function close() {
15+
this.removeAttribute('open');
16+
this.dispatchEvent(new Event('close'));
17+
};
18+
});
19+
20+
const brightData: ConnectorCatalogEntry = {
21+
id: 'bright-data',
22+
name: 'bright-data',
23+
description: 'Search the web and scrape pages, including sites behind bot protection.',
24+
url: 'https://mcp.brightdata.com/mcp',
25+
auth: { type: 'header', headerName: 'Authorization' },
26+
};
27+
28+
const connectedBrightData: ConnectorBase = {
29+
id: 'bright-data',
30+
name: 'bright-data',
31+
description: brightData.description ?? '',
32+
url: brightData.url,
33+
auth: { type: 'header', headerName: 'Authorization' },
34+
requiresAuth: false,
35+
authenticated: true,
36+
};
37+
38+
/** `succeedsWhen` decides, per attempt, whether the just-saved header value should test as reachable. */
39+
function renderConnectorSettings(succeedsWhen: (headerValue: string) => boolean) {
40+
let lastAuthValue: string | undefined;
41+
const attempts: string[] = [];
42+
const server = createMockAgentUIServer({
43+
catalog: createMockCatalog({
44+
connectorCatalog: {
45+
getConnectorCatalog: async () => [brightData],
46+
listConnectors: async () => [],
47+
getConnector: async () => connectedBrightData,
48+
getToolsByConnectorId: async () => {
49+
if (lastAuthValue === undefined || !succeedsWhen(lastAuthValue)) {
50+
throw new Error(`upstream returned 401 Unauthorized for "${lastAuthValue}"`);
51+
}
52+
return [];
53+
},
54+
createConnector: async req => {
55+
const value = req.auth.type === 'header' ? (req.auth.apiKey ?? '') : '';
56+
lastAuthValue = value;
57+
attempts.push(value);
58+
return connectedBrightData;
59+
},
60+
updateConnector: async req => {
61+
const value = req.auth.type === 'header' ? (req.auth.apiKey ?? '') : '';
62+
lastAuthValue = value;
63+
attempts.push(value);
64+
return connectedBrightData;
65+
},
66+
authenticateConnector: async () => ({ authorization_endpoint: '' }),
67+
disconnectConnector: async () => connectedBrightData,
68+
},
69+
}),
70+
});
71+
72+
render(
73+
<ServerProvider server={server}>
74+
<ConnectorSettings />
75+
</ServerProvider>,
76+
);
77+
78+
return { attempts };
79+
}
80+
81+
async function openConnectModal() {
82+
const row = await screen.findByText('bright-data');
83+
const connectButton = within(row.closest('article') as HTMLElement).getByRole('button', { name: 'Connect' });
84+
fireEvent.click(connectButton);
85+
return within(await screen.findByRole('dialog'));
86+
}
87+
88+
function submit(dialog: ReturnType<typeof within>, apiKey: string) {
89+
fireEvent.change(dialog.getByLabelText('API key / token'), { target: { value: apiKey } });
90+
fireEvent.click(dialog.getByRole('button', { name: /Connect|Testing/ }));
91+
}
92+
93+
describe('ConnectorSettings auto-probing header auth (fixes #490)', () => {
94+
it('stores the raw key when it works on the first try', async () => {
95+
const { attempts } = renderConnectorSettings(value => value === 'abc123');
96+
const dialog = await openConnectModal();
97+
submit(dialog, 'abc123');
98+
99+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
100+
expect(attempts).toEqual(['abc123']);
101+
});
102+
103+
it('falls back to a Bearer prefix when the raw key is rejected, logging each attempt', async () => {
104+
const { attempts } = renderConnectorSettings(value => value === 'Bearer abc123');
105+
const dialog = await openConnectModal();
106+
submit(dialog, 'abc123');
107+
108+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
109+
expect(attempts).toEqual(['abc123', 'Bearer abc123']);
110+
});
111+
112+
it('falls back to Basic when both the raw key and Bearer are rejected', async () => {
113+
const { attempts } = renderConnectorSettings(value => value === 'Basic abc123');
114+
const dialog = await openConnectModal();
115+
submit(dialog, 'abc123');
116+
117+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
118+
expect(attempts).toEqual(['abc123', 'Bearer abc123', 'Basic abc123']);
119+
});
120+
121+
it('shows a final error, keeps the dialog open, and logs every failed attempt when all candidates fail', async () => {
122+
const { attempts } = renderConnectorSettings(() => false);
123+
const dialog = await openConnectModal();
124+
submit(dialog, 'abc123');
125+
126+
expect(await dialog.findByText(/Could not connect with the provided key/)).toBeInTheDocument();
127+
expect(attempts).toEqual(['abc123', 'Bearer abc123', 'Basic abc123']);
128+
expect(screen.getByRole('dialog')).toBeInTheDocument();
129+
expect(dialog.getByText(/Testing key failed/)).toBeInTheDocument();
130+
expect(dialog.getByText(/Trying with prefix "Bearer" failed/)).toBeInTheDocument();
131+
expect(dialog.getByText(/Trying with prefix "Basic" failed/)).toBeInTheDocument();
132+
});
133+
134+
it('does not double-prefix a key already typed with "Bearer "', async () => {
135+
const { attempts } = renderConnectorSettings(value => value === 'Basic Bearer abc123');
136+
const dialog = await openConnectModal();
137+
submit(dialog, 'Bearer abc123');
138+
139+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
140+
// No separate "Bearer Bearer abc123" attempt — the raw try already carried the prefix.
141+
expect(attempts).toEqual(['Bearer abc123', 'Basic Bearer abc123']);
142+
});
143+
});

0 commit comments

Comments
 (0)