feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration.
This commit is contained in:
@@ -2,10 +2,29 @@ import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { MCP_OAUTH_ORIGIN_DESKTOP } from '@/components/sections/mcp/startMcpAuthorization';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { SETTINGS_PAGE_TITLE_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Handing control back after the browser finished the authorization.
|
||||
*
|
||||
* This page always runs in a browser, but the flow may have been started from
|
||||
* the desktop shell — a different surface entirely. Sending that user to `/`
|
||||
* would raise a second copy of the interface in a tab while the real app sits
|
||||
* behind it, so the desktop case is returned through its own protocol, which
|
||||
* focuses the running window.
|
||||
*/
|
||||
const returnToApp = (startedFromDesktop: boolean): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (startedFromDesktop) {
|
||||
window.location.href = 'openchamber://focus/mcp-auth';
|
||||
return;
|
||||
}
|
||||
window.location.replace('/');
|
||||
};
|
||||
|
||||
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
|
||||
const value = params.get(key);
|
||||
if (typeof value !== 'string') {
|
||||
@@ -27,6 +46,7 @@ const normalizeMcpAuthErrorMessage = (error: unknown, fallback: string): string
|
||||
export const McpOAuthCallbackPage: React.FC = () => {
|
||||
const completeAuth = useMcpStore((state) => state.completeAuth);
|
||||
const [status, setStatus] = React.useState<'working' | 'success' | 'error'>('working');
|
||||
const [returnToDesktop, setReturnToDesktop] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('Completing MCP authorization...');
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -59,11 +79,21 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
}
|
||||
|
||||
let pendingContext = callbackContext;
|
||||
if (!pendingContext && callbackStateKey) {
|
||||
let startedFromDesktop = false;
|
||||
// Always consulted, even when the state already carries the server:
|
||||
// the origin lives only here, and it decides where the user is sent
|
||||
// back to.
|
||||
if (callbackStateKey) {
|
||||
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`);
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { name?: string; directory?: string | null } | null;
|
||||
if (payload?.name?.trim()) {
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
name?: string;
|
||||
directory?: string | null;
|
||||
origin?: string | null;
|
||||
} | null;
|
||||
startedFromDesktop = payload?.origin === MCP_OAUTH_ORIGIN_DESKTOP;
|
||||
setReturnToDesktop(startedFromDesktop);
|
||||
if (!pendingContext && payload?.name?.trim()) {
|
||||
pendingContext = {
|
||||
name: payload.name.trim(),
|
||||
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
|
||||
@@ -81,6 +111,12 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
|
||||
}
|
||||
setStatus('success');
|
||||
// Attempted straight away: the user's attention is in a browser tab,
|
||||
// and the app they were working in is behind it. The button below
|
||||
// stays as the fallback for a browser that blocks the protocol jump.
|
||||
if (startedFromDesktop) {
|
||||
returnToApp(true);
|
||||
}
|
||||
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
|
||||
} catch (authError) {
|
||||
if (callbackStateKey) {
|
||||
@@ -117,12 +153,7 @@ export const McpOAuthCallbackPage: React.FC = () => {
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.location.replace('/');
|
||||
}}
|
||||
onClick={() => returnToApp(returnToDesktop)}
|
||||
>
|
||||
Return to OpenChamber
|
||||
</Button>
|
||||
|
||||
@@ -20,21 +20,20 @@ import {
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsFieldRow,
|
||||
SettingsCheckboxRow,
|
||||
SettingsStackedField,
|
||||
SettingsChipGroup,
|
||||
SettingsGroupTitle,
|
||||
SettingsStackedField,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_FIELD_LABEL_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
|
||||
import { buildMcpAuthorizationRedirectUri, startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -59,11 +59,9 @@ import { useI18n } from '@/lib/i18n';
|
||||
interface CommandTextareaProps {
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
pasteCommandTitle: string;
|
||||
pasteCommandLabel: string;
|
||||
pasteSuccess: (count: number) => string;
|
||||
clipboardReadFailed: string;
|
||||
preview: (count: number) => string;
|
||||
/** Called when the text is plainly a link rather than a command. */
|
||||
onDetectUrl?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,11 +122,8 @@ function extractAuthorizationResponse(raw: string): {
|
||||
const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
pasteCommandTitle,
|
||||
pasteCommandLabel,
|
||||
pasteSuccess,
|
||||
clipboardReadFailed,
|
||||
preview,
|
||||
onDetectUrl,
|
||||
}) => {
|
||||
// Internal: one arg per line
|
||||
const [text, setText] = React.useState(() => value.join('\n'));
|
||||
@@ -144,42 +139,38 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
||||
// A single line that is nothing but a URL is a hosted server, not a
|
||||
// command to run — the page switches kind rather than making the user say.
|
||||
if (onDetectUrl && lines.length === 1 && /^https?:\/\/\S+$/i.test(lines[0].trim())) {
|
||||
onDetectUrl(lines[0].trim());
|
||||
return;
|
||||
}
|
||||
onChange(lines);
|
||||
};
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
try {
|
||||
const raw = await navigator.clipboard.readText();
|
||||
const trimmed = raw.trim();
|
||||
// If it looks like a multi-line list, keep as-is; otherwise parse as shell command
|
||||
const lines = trimmed.includes('\n')
|
||||
? trimmed.split('\n').filter((l) => l.trim())
|
||||
: parseShellCommand(trimmed);
|
||||
setText(lines.join('\n'));
|
||||
onChange(lines);
|
||||
toast.success(pasteSuccess(lines.length));
|
||||
} catch {
|
||||
toast.error(clipboardReadFailed);
|
||||
}
|
||||
/**
|
||||
* Pasting a whole command line splits it into arguments here, in the field
|
||||
* the user pasted into. The old approach — a button that read the clipboard
|
||||
* itself — fails outright wherever the runtime denies clipboard reads.
|
||||
*/
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const raw = event.clipboardData.getData('text');
|
||||
const trimmed = raw.trim();
|
||||
// Only take over a paste that replaces the whole field with one command
|
||||
// line; anything else is ordinary editing and belongs to the browser.
|
||||
if (!trimmed || trimmed.includes('\n') || !/\s/.test(trimmed)) return;
|
||||
const target = event.currentTarget;
|
||||
if (target.selectionStart !== 0 || target.selectionEnd !== target.value.length) return;
|
||||
event.preventDefault();
|
||||
const lines = parseShellCommand(trimmed);
|
||||
setText(lines.join('\n'));
|
||||
onChange(lines);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-bwignore="true" data-1p-ignore="true" data-lpignore="true">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
onClick={handlePasteFromClipboard}
|
||||
type="button"
|
||||
title={pasteCommandTitle}
|
||||
>
|
||||
<Icon name="clipboard" className="h-3 w-3" />
|
||||
{pasteCommandLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
onPaste={handlePaste}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
@@ -198,7 +189,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({
|
||||
'npx\n-y\n@modelcontextprotocol/server-postgres\npostgresql://user:pass@host/db'
|
||||
}
|
||||
rows={Math.max(4, value.length + 1)}
|
||||
className="font-mono typography-meta resize-y min-h-[80px]"
|
||||
className="font-mono typography-meta min-h-[80px]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -508,42 +499,6 @@ const shouldShowFullStatusCard = (status: string | undefined, authUrl: string |
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildMcpOAuthRedirectUri = (name?: string | null, directory?: string | null): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
url.searchParams.set('server', name.trim());
|
||||
}
|
||||
if (typeof directory === 'string' && directory.trim()) {
|
||||
url.searchParams.set('directory', directory.trim());
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const queuePendingMcpAuthContext = async (input: {
|
||||
state: string;
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
}): Promise<void> => {
|
||||
const response = await runtimeFetch('/api/mcp/auth/pending', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
state: input.state,
|
||||
name: input.name,
|
||||
directory: typeof input.directory === 'string' && input.directory.trim() ? input.directory.trim() : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error || 'Failed to prepare MCP authorization callback');
|
||||
}
|
||||
};
|
||||
|
||||
const getPendingMcpAuthContext = async (stateKey: string): Promise<{ name: string; directory: string | null } | null> => {
|
||||
const response = await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(stateKey)}`);
|
||||
if (!response.ok) {
|
||||
@@ -626,7 +581,6 @@ export const McpPage: React.FC = () => {
|
||||
const refreshStatus = useMcpStore((state) => state.refresh);
|
||||
const connectMcp = useMcpStore((state) => state.connect);
|
||||
const disconnectMcp = useMcpStore((state) => state.disconnect);
|
||||
const startAuthMcp = useMcpStore((state) => state.startAuth);
|
||||
const completeAuthMcp = useMcpStore((state) => state.completeAuth);
|
||||
const clearAuthMcp = useMcpStore((state) => state.clearAuth);
|
||||
const testConnectionMcp = useMcpStore((state) => state.testConnection);
|
||||
@@ -887,6 +841,39 @@ export const McpPage: React.FC = () => {
|
||||
);
|
||||
}, [mcpType, command, url, envEntries, headerEntries, oauthEnabled, oauthClientId, oauthClientSecret, oauthScope, oauthRedirectUri, timeout, enabled]);
|
||||
|
||||
// What the user has is either a command they were given or a link. Which of
|
||||
// the two decides the transport, so the page reads it off the text instead of
|
||||
// asking — and lets them correct it when the text alone cannot say.
|
||||
const connectionKindTabs = React.useMemo<SortableTabsStripItem[]>(() => [
|
||||
{
|
||||
id: 'local',
|
||||
label: t('settings.mcp.page.connection.kindCommand'),
|
||||
icon: <Icon name="terminal" className="h-3.5 w-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'remote',
|
||||
label: t('settings.mcp.page.connection.kindLink'),
|
||||
icon: <Icon name="global" className="h-3.5 w-3.5" />,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const handleDetectedUrl = React.useCallback((candidate: string) => {
|
||||
setMcpType('remote');
|
||||
setUrl(candidate);
|
||||
setCommand([]);
|
||||
}, []);
|
||||
|
||||
const handleUrlChange = React.useCallback((next: string) => {
|
||||
setUrl(next);
|
||||
// A command pasted into the link field is still a command.
|
||||
const trimmed = next.trim();
|
||||
if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /\s/.test(trimmed)) {
|
||||
setMcpType('local');
|
||||
setCommand(parseShellCommand(trimmed));
|
||||
setUrl('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = isNewServer ? draftName.trim() : selectedMcpName ?? '';
|
||||
if (!name) { toast.error(t('settings.mcp.page.toast.nameRequired')); return; }
|
||||
@@ -1042,48 +1029,20 @@ export const McpPage: React.FC = () => {
|
||||
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
|
||||
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
|
||||
|
||||
const redirectUri = buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
|
||||
if (!redirectUri) {
|
||||
throw new Error(t('settings.mcp.page.toast.oauthRedirectUrlBuildFailed'));
|
||||
}
|
||||
|
||||
if (!oauthRedirectUri.trim() && !isVSCodeAuthRuntime) {
|
||||
const saved = await updateMcp(selectedMcpName, {
|
||||
oauthEnabled,
|
||||
oauthClientId,
|
||||
oauthClientSecret,
|
||||
oauthScope,
|
||||
oauthRedirectUri: redirectUri,
|
||||
});
|
||||
|
||||
if (!saved.ok) {
|
||||
throw new Error(t('settings.mcp.page.toast.oauthBrowserCallbackSaveFailed'));
|
||||
}
|
||||
|
||||
if (saved.reloadFailed) {
|
||||
throw new Error(saved.warning || saved.message || t('settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave'));
|
||||
}
|
||||
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOauthRedirectUri(redirectUri);
|
||||
initialRef.current = initialRef.current
|
||||
? { ...initialRef.current, oauthRedirectUri: redirectUri }
|
||||
: initialRef.current;
|
||||
}
|
||||
|
||||
const nextAuthUrl = await startAuthMcp(selectedMcpName, currentDirectory);
|
||||
// One implementation for every surface that can authorise; the page
|
||||
// used to own this flow while the dropdown and the work-status panel
|
||||
// called plain `connect`, which cannot start OAuth at all.
|
||||
const { authorizationUrl: nextAuthUrl, opened } = await startMcpAuthorization({
|
||||
name: selectedMcpName,
|
||||
directory: currentDirectory,
|
||||
// Only VS Code keeps OpenCode's own redirect. Skipping whenever some
|
||||
// value was stored left a stale one — a dead loopback port from an
|
||||
// earlier launch — unrepairable from this page; the bootstrap already
|
||||
// rewrites nothing when the stored value is right.
|
||||
skipRedirectUriBootstrap: isVSCodeAuthRuntime,
|
||||
});
|
||||
const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams);
|
||||
if (stateKey) {
|
||||
queuedStateKey = stateKey;
|
||||
await queuePendingMcpAuthContext({
|
||||
state: stateKey,
|
||||
name: selectedMcpName,
|
||||
directory: currentDirectory,
|
||||
});
|
||||
}
|
||||
queuedStateKey = stateKey;
|
||||
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
@@ -1094,7 +1053,6 @@ export const McpPage: React.FC = () => {
|
||||
setIsAuthPolling(true);
|
||||
authPollAttemptsRef.current = 0;
|
||||
|
||||
const opened = await openExternalUrl(nextAuthUrl);
|
||||
if (runtimeActionKeyRef.current !== actionKey) {
|
||||
return;
|
||||
}
|
||||
@@ -1118,7 +1076,7 @@ export const McpPage: React.FC = () => {
|
||||
setIsAuthorizing(false);
|
||||
}
|
||||
}
|
||||
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, oauthClientId, oauthClientSecret, oauthEnabled, oauthRedirectUri, oauthScope, requireSavedConfig, runtimeActionKey, selectedMcpName, startAuthMcp, t, tUnsafe, updateMcp]);
|
||||
}, [currentDirectory, isVSCodeAuthRuntime, mcpType, requireSavedConfig, runtimeActionKey, selectedMcpName, t, tUnsafe]);
|
||||
|
||||
const handleClearAuthorization = React.useCallback(async () => {
|
||||
if (!selectedMcpName || !requireSavedConfig()) return;
|
||||
@@ -1313,7 +1271,25 @@ export const McpPage: React.FC = () => {
|
||||
const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic;
|
||||
const isConnected = runtimeStatus?.status === 'connected';
|
||||
const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration';
|
||||
const suggestedRedirectUri = isVSCodeAuthRuntime ? null : buildMcpOAuthRedirectUri(selectedMcpName, currentDirectory);
|
||||
// Must be the very URI `startMcpAuthorization` writes into the config, not a
|
||||
// second construction of it. The page used to suggest a directory-bearing
|
||||
// address while the flow sent a directory-less one, so a provider enforcing
|
||||
// exact redirect matching rejected a registration copied from right here.
|
||||
const suggestedRedirectUri = isVSCodeAuthRuntime || !selectedMcpName
|
||||
? null
|
||||
: buildMcpAuthorizationRedirectUri(selectedMcpName);
|
||||
|
||||
const handleCopyRedirectUri = async () => {
|
||||
if (!suggestedRedirectUri) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(suggestedRedirectUri);
|
||||
toast.success(t('settings.mcp.page.toast.copiedCallbackUrl'));
|
||||
} catch {
|
||||
toast.error(t('settings.mcp.page.toast.clipboardWriteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const runtimeDescription = getStatusDescription(
|
||||
effectiveRuntimeStatus?.status,
|
||||
tUnsafe,
|
||||
@@ -1431,6 +1407,69 @@ export const McpPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client credentials appear only when the server has said it
|
||||
needs them. Kept as a permanent four-field form, they made
|
||||
the rarest case the most prominent thing on the page and
|
||||
told nobody what to put there. */}
|
||||
{effectiveRuntimeStatus?.status === 'needs_client_registration' && (
|
||||
<div className="space-y-3 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
||||
<div>
|
||||
<SettingsGroupTitle as="div">{t('settings.mcp.page.registration.title')}</SettingsGroupTitle>
|
||||
<p className="mt-1 typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{suggestedRedirectUri && (
|
||||
<div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.callbackLabel')}
|
||||
</div>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<span className="min-w-0 flex-1 break-all font-mono typography-micro text-foreground/80">
|
||||
{suggestedRedirectUri}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void handleCopyRedirectUri()}
|
||||
>
|
||||
<Icon name="clipboard" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.actions.copyLink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 @xl:grid-cols-2">
|
||||
<SettingsStackedField label={t('settings.mcp.page.registration.clientId')}>
|
||||
<Input
|
||||
value={oauthClientId}
|
||||
onChange={(e) => { setOauthClientId(e.target.value); setOauthEnabled(true); }}
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
<SettingsStackedField label={t('settings.mcp.page.registration.clientSecret')}>
|
||||
<Input
|
||||
type="password"
|
||||
value={oauthClientSecret}
|
||||
onChange={(e) => { setOauthClientSecret(e.target.value); setOauthEnabled(true); }}
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
</div>
|
||||
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.registration.afterSaving')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUrl && (
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-2">
|
||||
<div className="space-y-2">
|
||||
@@ -1450,7 +1489,11 @@ export const McpPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
|
||||
{/* VS Code only. Everywhere else the callback returns into the
|
||||
app on its own, so the paste box was a second, confusing way
|
||||
to do what already happened. VS Code cannot receive that
|
||||
redirect, so there it remains the only way to finish. */}
|
||||
{isVSCodeAuthRuntime && mcpType === 'remote' && (needsAuthorization || isAuthPolling || authUrl) && (
|
||||
<div className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
@@ -1464,7 +1507,7 @@ export const McpPage: React.FC = () => {
|
||||
onChange={(event) => setAuthCallbackInput(event.target.value)}
|
||||
placeholder={t('settings.mcp.page.auth.callbackInputPlaceholder')}
|
||||
rows={3}
|
||||
className="font-mono typography-meta resize-y"
|
||||
className="font-mono typography-meta"
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
spellCheck={false}
|
||||
@@ -1499,32 +1542,60 @@ export const McpPage: React.FC = () => {
|
||||
divider={false}
|
||||
settingsItem="mcp.server"
|
||||
contentClassName="space-y-0"
|
||||
titleAccessory={isNewServer ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1.5 text-muted-foreground"
|
||||
onClick={handleOpenImportDialog}
|
||||
type="button"
|
||||
title={t('settings.mcp.page.server.importJsonTitle')}
|
||||
>
|
||||
<Icon name="file-code" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.server.importJson')}
|
||||
</Button>
|
||||
) : null}
|
||||
>
|
||||
|
||||
{isNewServer && (
|
||||
<SettingsFieldRow label={t('settings.mcp.page.server.name')}>
|
||||
<SettingsFieldRow
|
||||
label={t('settings.mcp.page.server.name')}
|
||||
// The scope select carries words now, not a lone icon, so the
|
||||
// control cluster has to be allowed to bound itself and wrap.
|
||||
// Left at its default (fit-width, no shrink) the pair ran past
|
||||
// the edge of the settings pane in a narrow dialog.
|
||||
controlClassName="flex-wrap @xl:w-auto @xl:flex-1"
|
||||
>
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-'))}
|
||||
placeholder={t('settings.mcp.page.server.namePlaceholder')}
|
||||
className="h-7 w-48 font-mono px-2"
|
||||
className="h-7 w-48 min-w-0 max-w-full shrink font-mono px-2"
|
||||
autoFocus
|
||||
/>
|
||||
<Select value={draftScope} onValueChange={(value) => setDraftScope(value as McpScope)}>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 !w-7 !min-w-0 !px-0 !py-0 justify-center [&>svg:last-child]:hidden" title={draftScope === 'user' ? t('settings.common.scope.global') : t('settings.common.scope.project')}>
|
||||
{draftScope === 'user' ? <Icon name="user-3" className="h-3.5 w-3.5" /> : <Icon name="folder" className="h-3.5 w-3.5" />}
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className="!h-7 min-w-0 max-w-full gap-1.5 px-2">
|
||||
<Icon
|
||||
name={draftScope === 'user' ? 'user-3' : 'folder'}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{draftScope === 'user'
|
||||
? t('settings.mcp.page.scope.everywhere')
|
||||
: t('settings.mcp.page.scope.thisProject')}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="user">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="user-3" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.global')}</span>
|
||||
<span>{t('settings.mcp.page.scope.everywhere')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="project">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="folder" className="h-3.5 w-3.5" />
|
||||
<span>{t('settings.common.scope.project')}</span>
|
||||
<span>{t('settings.mcp.page.scope.thisProject')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -1532,23 +1603,6 @@ export const McpPage: React.FC = () => {
|
||||
</SettingsFieldRow>
|
||||
)}
|
||||
|
||||
{/* Import JSON - prominent placement for new servers */}
|
||||
{isNewServer && (
|
||||
<div className="py-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal gap-1.5"
|
||||
onClick={handleOpenImportDialog}
|
||||
type="button"
|
||||
title={t('settings.mcp.page.server.importJsonTitle')}
|
||||
>
|
||||
<Icon name="file-code" className="h-3.5 w-3.5" />
|
||||
{t('settings.mcp.page.server.importJson')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsCheckboxRow
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
@@ -1556,42 +1610,50 @@ export const McpPage: React.FC = () => {
|
||||
ariaLabel={t('settings.mcp.page.server.enableAria')}
|
||||
/>
|
||||
|
||||
<SettingsStackedField label={t('settings.mcp.page.server.transportMode')}>
|
||||
<SettingsChipGroup
|
||||
aria-label={t('settings.mcp.page.server.transportMode')}
|
||||
value={mcpType}
|
||||
onChange={setMcpType}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.mcp.page.transport.local') },
|
||||
{ value: 'remote', label: t('settings.mcp.page.transport.remote') },
|
||||
]}
|
||||
/>
|
||||
</SettingsStackedField>
|
||||
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={mcpType === 'local' ? t('settings.mcp.page.connection.command') : t('settings.mcp.page.connection.serverUrl')}
|
||||
title={t('settings.mcp.page.connection.title')}
|
||||
description={t('settings.mcp.page.connection.description')}
|
||||
settingsItem="mcp.command"
|
||||
// The section's content wrapper carries no spacing of its own, so the
|
||||
// kind tabs, the field and its hint would otherwise sit flush.
|
||||
contentClassName="space-y-2"
|
||||
>
|
||||
{/* Pasting a link or a command still flips this for you, but the
|
||||
choice is a control you can see and press. As one sentence with
|
||||
an inline link it was, in practice, undiscoverable. */}
|
||||
<SortableTabsStrip
|
||||
items={connectionKindTabs}
|
||||
activeId={mcpType}
|
||||
onSelect={(id) => setMcpType(id as 'local' | 'remote')}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillLowercase={false}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
{mcpType === 'local' ? (
|
||||
<CommandTextarea
|
||||
value={command}
|
||||
onChange={setCommand}
|
||||
pasteCommandTitle={t('settings.mcp.page.connection.pasteCommandTitle')}
|
||||
pasteCommandLabel={t('settings.mcp.page.connection.pasteCommand')}
|
||||
pasteSuccess={(count) => t('settings.mcp.page.toast.pastedArgumentsCount', { count })}
|
||||
clipboardReadFailed={t('settings.mcp.page.toast.clipboardReadFailed')}
|
||||
preview={(count) => t('settings.mcp.page.connection.previewArgs', { count })}
|
||||
onDetectUrl={handleDetectedUrl}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.connection.serverUrlPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{mcpType === 'local'
|
||||
? t('settings.mcp.page.connection.hintCommand')
|
||||
: t('settings.mcp.page.connection.hintLink')}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
{mcpType === 'remote' && (
|
||||
@@ -1607,7 +1669,9 @@ export const McpPage: React.FC = () => {
|
||||
<div className="flex items-center gap-1.5 text-left">
|
||||
<span className="typography-ui-label font-normal text-foreground">{t('settings.mcp.page.advanced.configure')}</span>
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
({oauthEnabled ? t('settings.mcp.page.advanced.autoDetect') : t('settings.mcp.page.advanced.custom')} · {headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
|
||||
{/* OAuth left the form, so the summary stops reporting a
|
||||
setting the user can no longer see. */}
|
||||
({headerEntries.length} {t('settings.mcp.page.advanced.headers')}{timeout ? ` · ${timeout}ms` : ''})
|
||||
</span>
|
||||
</div>
|
||||
{isAdvancedRemoteOptionsOpen ? (
|
||||
@@ -1669,61 +1733,6 @@ export const McpPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SettingsCheckboxRow
|
||||
checked={oauthEnabled}
|
||||
onChange={setOauthEnabled}
|
||||
label={t('settings.mcp.page.advanced.oauthAutoDetection')}
|
||||
ariaLabel={t('settings.mcp.page.advanced.oauthAutoDetectionAria')}
|
||||
info={t('settings.mcp.page.advanced.oauthHint')}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 @xl:grid-cols-2">
|
||||
<Input
|
||||
value={oauthClientId}
|
||||
onChange={(e) => setOauthClientId(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthClientIdPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthClientSecret}
|
||||
onChange={(e) => setOauthClientSecret(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthClientSecretPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthScope}
|
||||
onChange={(e) => setOauthScope(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthScopesPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<Input
|
||||
value={oauthRedirectUri}
|
||||
onChange={(e) => setOauthRedirectUri(e.target.value)}
|
||||
placeholder={t('settings.mcp.page.advanced.oauthRedirectUriPlaceholder')}
|
||||
className="font-mono typography-meta"
|
||||
disabled={!oauthEnabled}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{suggestedRedirectUri && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.mcp.page.advanced.oauthCallbackHint')}
|
||||
<span className="mt-1 block break-all font-mono text-foreground/80">{suggestedRedirectUri}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -1732,6 +1741,7 @@ export const McpPage: React.FC = () => {
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.mcp.page.env.title')}
|
||||
description={t('settings.mcp.page.env.description')}
|
||||
titleAccessory={
|
||||
envEntries.length > 0 ? (
|
||||
<span className="typography-micro text-muted-foreground font-normal">
|
||||
@@ -1823,7 +1833,7 @@ export const McpPage: React.FC = () => {
|
||||
}}
|
||||
placeholder={'{\n "mcpServers": {\n "postgres": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-postgres"]\n }\n }\n}'}
|
||||
rows={8}
|
||||
className="font-mono typography-meta resize-y"
|
||||
className="font-mono typography-meta"
|
||||
spellCheck={false}
|
||||
data-bwignore="true"
|
||||
data-1p-ignore="true"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
|
||||
|
||||
/**
|
||||
* Starting MCP authorization, for every surface that offers it.
|
||||
*
|
||||
* A server in `needs_auth` cannot be fixed by reconnecting: `POST /mcp/:name/connect`
|
||||
* just repeats the attempt that produced `needs_auth` in the first place. The
|
||||
* flow OpenCode expects is explicit — ask for an authorization URL, send the
|
||||
* user to it, then hand the returned code back:
|
||||
*
|
||||
* POST /mcp/:name/auth → { authorizationUrl, oauthState }
|
||||
* (user authorises in a browser)
|
||||
* POST /mcp/:name/auth/callback → status
|
||||
*
|
||||
* OpenCode does not open the browser for this flow; that is the caller's job.
|
||||
*
|
||||
* The redirect URI matters as much as the call. Without one of ours in the
|
||||
* server's config, OpenCode falls back to its own loopback listener on
|
||||
* 127.0.0.1 — which only works when the browser runs on the same machine as
|
||||
* the OpenCode process. For a remote or web client the callback would simply
|
||||
* never arrive, so the first authorization writes our own callback URL into
|
||||
* the config before asking for the URL.
|
||||
*/
|
||||
|
||||
type McpAuthorizationStart = {
|
||||
authorizationUrl: string;
|
||||
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
class McpAuthorizationError extends Error {}
|
||||
|
||||
/**
|
||||
* The callback lands in the system browser, which is a different surface from
|
||||
* the desktop app. Recording where the flow began lets the callback page hand
|
||||
* control back correctly: a browser session returns to the app it is already
|
||||
* showing, while the desktop shell has to be raised through its own deep link.
|
||||
*
|
||||
* This travels with the pending context, not in the redirect URI. That URI is
|
||||
* written into the server's config once and never rewritten, so a marker
|
||||
* encoded there would be frozen at whatever runtime happened to authorise
|
||||
* first — a desktop user would keep being sent to the web UI forever.
|
||||
*/
|
||||
export const MCP_OAUTH_ORIGIN_DESKTOP = 'desktop';
|
||||
|
||||
/**
|
||||
* Stable for a given server, whatever session is open.
|
||||
*
|
||||
* It used to carry the directory as well, which made the address different for
|
||||
* every worktree: switching sessions produced a new value, so the config was
|
||||
* rewritten and OpenCode reloaded in front of the user. The directory is not
|
||||
* needed here — authorization is not per-directory — and the pending context
|
||||
* parked under the OAuth `state` carries it for the completion call.
|
||||
*
|
||||
* The server name stays. It never varies for a given entry, since the redirect
|
||||
* lives in that entry's own config, and it lets the callback page identify the
|
||||
* server straight from the URL rather than depending solely on server-side
|
||||
* memory surviving the reload this very write triggers.
|
||||
*/
|
||||
export const buildMcpAuthorizationRedirectUri = (name: string): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new McpAuthorizationError('No browser context to build a callback URL from');
|
||||
}
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
|
||||
url.searchParams.set('server', name);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Correlates the eventual browser redirect with the server it belongs to. The
|
||||
* callback page has only the OAuth `state` to go on, so the pair is parked
|
||||
* server-side under that key.
|
||||
*/
|
||||
const queuePendingContext = async (input: {
|
||||
state: string;
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
origin: string | null;
|
||||
}): Promise<void> => {
|
||||
const response = await runtimeFetch('/api/mcp/auth/pending', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
state: input.state,
|
||||
name: input.name,
|
||||
directory: input.directory?.trim() ? input.directory.trim() : null,
|
||||
origin: input.origin,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new McpAuthorizationError(payload?.error || 'Failed to prepare the MCP authorization callback');
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingContext = async (state: string | null): Promise<void> => {
|
||||
if (!state) return;
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(state)}`, { method: 'DELETE' })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
/** How long the user plausibly spends authorising before giving up on them. */
|
||||
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
|
||||
const AUTHORIZATION_POLL_MS = 1_500;
|
||||
|
||||
const waitForAuthorizationThenFocus = async (name: string, directory: string | null): Promise<void> => {
|
||||
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, AUTHORIZATION_POLL_MS));
|
||||
try {
|
||||
await useMcpStore.getState().refresh({ directory, silent: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const status = useMcpStore.getState().getStatusForDirectory(directory)[name]?.status;
|
||||
if (status === 'connected') {
|
||||
void focusDesktopWindow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const startMcpAuthorization = async (input: {
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
/** VS Code cannot receive our callback route, so it keeps OpenCode's own redirect. */
|
||||
skipRedirectUriBootstrap?: boolean;
|
||||
}): Promise<McpAuthorizationStart> => {
|
||||
const { name, directory } = input;
|
||||
let queuedState: string | null = null;
|
||||
|
||||
try {
|
||||
if (!input.skipRedirectUriBootstrap) {
|
||||
// The config has to be loaded before its absence can mean anything. On
|
||||
// the first authorization after launch the store is often still empty,
|
||||
// and reading it then reported "no redirect URI" for a server that had
|
||||
// one — so the config was rewritten needlessly and OpenCode reloaded in
|
||||
// front of the user for no reason.
|
||||
if (!useMcpConfigStore.getState().getMcpByName(name)) {
|
||||
await useMcpConfigStore.getState().loadMcpConfigs();
|
||||
}
|
||||
|
||||
const configStore = useMcpConfigStore.getState();
|
||||
const existing = configStore.getMcpByName(name);
|
||||
// `oauth: false` means the user disabled it explicitly.
|
||||
const currentOAuth = existing && 'oauth' in existing && existing.oauth
|
||||
? existing.oauth
|
||||
: null;
|
||||
|
||||
// Rewritten when it does not match the callback we would receive right
|
||||
// now — not merely when it is missing.
|
||||
//
|
||||
// The desktop app's loopback port changes between launches, so a stored
|
||||
// redirect from an earlier session points at a port nothing serves any
|
||||
// more: the provider redirects into the void and authorization never
|
||||
// completes. Comparing instead of checking for absence also means the
|
||||
// config is left alone — and OpenCode is not reloaded — whenever the
|
||||
// stored value is already right, which is every run after the first.
|
||||
const desiredRedirectUri = buildMcpAuthorizationRedirectUri(name);
|
||||
if (existing && currentOAuth?.redirectUri !== desiredRedirectUri) {
|
||||
const saved = await configStore.updateMcp(name, {
|
||||
oauthEnabled: true,
|
||||
oauthClientId: currentOAuth?.clientId ?? '',
|
||||
oauthClientSecret: currentOAuth?.clientSecret ?? '',
|
||||
oauthScope: currentOAuth?.scope ?? '',
|
||||
oauthRedirectUri: desiredRedirectUri,
|
||||
});
|
||||
if (!saved.ok) {
|
||||
throw new McpAuthorizationError(
|
||||
saved.message || 'Failed to save the authorization callback URL',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorizationUrl = await useMcpStore.getState().startAuth(name, directory ?? null);
|
||||
|
||||
const state = parseMcpOAuthCallbackStateKey(new URL(authorizationUrl).searchParams);
|
||||
if (state) {
|
||||
queuedState = state;
|
||||
await queuePendingContext({
|
||||
state,
|
||||
name,
|
||||
directory,
|
||||
origin: isDesktopShell() ? MCP_OAUTH_ORIGIN_DESKTOP : null,
|
||||
});
|
||||
}
|
||||
|
||||
const opened = await openExternalUrl(authorizationUrl);
|
||||
|
||||
// The desktop app raises itself once the server reports success, rather
|
||||
// than waiting for the browser to hand control back. A browser will not
|
||||
// follow a custom-protocol link without a user gesture, and the completion
|
||||
// page has none — so the return trip cannot start from there.
|
||||
if (opened && isDesktopShell()) {
|
||||
void waitForAuthorizationThenFocus(name, directory ?? null);
|
||||
}
|
||||
|
||||
return { authorizationUrl, opened };
|
||||
} catch (error) {
|
||||
// A parked context whose flow never started would later resolve a stale
|
||||
// server for an unrelated callback.
|
||||
await clearPendingContext(queuedState);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -64,15 +64,21 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
|
||||
)}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="mb-2 flex items-start justify-between gap-4 pb-6">
|
||||
<div className="min-w-0 space-y-1">
|
||||
// Wraps rather than squeezes. The action cluster never shrinks, so on
|
||||
// a narrow pane it used to starve the title until the name was a
|
||||
// single letter and an ellipsis; giving the title block a basis lets
|
||||
// the actions drop to their own line instead.
|
||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-3 pb-6">
|
||||
<div className="min-w-0 flex-1 basis-64 space-y-1">
|
||||
{title != null ? (
|
||||
isPlainTitle ? (
|
||||
hasTitleChrome ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{titleLeading}
|
||||
<h1 className={cn(SETTINGS_PAGE_TITLE_CLASS, 'min-w-0 truncate')}>{title}</h1>
|
||||
{titleAccessory}
|
||||
{/* A status badge carries a fixed word; compressing it
|
||||
wraps the text inside its own pill. */}
|
||||
<span className="shrink-0">{titleAccessory}</span>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className={SETTINGS_PAGE_TITLE_CLASS}>{title}</h1>
|
||||
@@ -89,7 +95,7 @@ export const SettingsPageLayout: React.FC<SettingsPageLayoutProps> = ({
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
|
||||
{headerEnd}
|
||||
{showSaveStatus && <SettingsSaveStatus />}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user