Improve MCP settings auth flow, remote config support, and diagnostics UX (#953)

* feat: improve MCP settings auth workflow

* fix: complete MCP settings auth flow

* fix: harden MCP settings auth flow

* fix: add MCP settings refresh control

* fix: stabilize MCP authorization and status handling

* fix: clarify MCP advanced remote options toggle

* fix: improve MCP import and diagnostics

* feat: improve MCP settings panel visual hierarchy and UX

* fix: expose MCP auth actions in connected state

* fix: remove MCP import snippet helper text

* fix: address MCP review feedback

* fix: correct MCP page transport layout after rebase
This commit is contained in:
Dave Otero
2026-04-21 20:46:51 +03:00
committed by GitHub
parent b1a96c7b36
commit d73edc672e
16 changed files with 2554 additions and 131 deletions
+124 -18
View File
@@ -11,6 +11,13 @@ import { opencodeClient } from '@/lib/opencode/client';
export type McpScope = 'user' | 'project';
type McpMutationResult = {
ok: boolean;
reloadFailed?: boolean;
message?: string;
warning?: string;
};
const getConfigDirectory = (): string | null => {
try {
const projectsStore = useProjectsStore.getState();
@@ -38,10 +45,20 @@ export interface McpLocalConfig {
enabled: boolean;
}
export interface McpOAuthConfig {
clientId?: string;
clientSecret?: string;
scope?: string;
redirectUri?: string;
}
export interface McpRemoteConfig {
type: 'remote';
url: string;
environment?: Record<string, string>;
headers?: Record<string, string>;
oauth?: McpOAuthConfig | false;
timeout?: number;
enabled: boolean;
}
@@ -55,6 +72,13 @@ export interface McpDraft {
command: string[];
url: string;
environment: Array<{ key: string; value: string }>;
headers: Array<{ key: string; value: string }>;
oauthEnabled: boolean;
oauthClientId: string;
oauthClientSecret: string;
oauthScope: string;
oauthRedirectUri: string;
timeout: string;
enabled: boolean;
}
@@ -71,6 +95,12 @@ export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Re
return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value]));
};
const trimOptionalString = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
};
const CLIENT_RELOAD_DELAY_MS = 800;
const MCP_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_MCP_CACHE_KEY = '__default__';
@@ -91,13 +121,17 @@ interface McpConfigStore {
setSelectedMcp: (name: string | null) => void;
setMcpDraft: (draft: McpDraft | null) => void;
loadMcpConfigs: () => Promise<boolean>;
createMcp: (config: McpDraft) => Promise<boolean>;
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<boolean>;
deleteMcp: (name: string) => Promise<boolean>;
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
deleteMcp: (name: string) => Promise<McpMutationResult>;
getMcpByName: (name: string) => McpServerWithScope | undefined;
}
const invalidateMcpCache = (directory: string | null) => {
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
};
export const useMcpConfigStore = create<McpConfigStore>()(
devtools(
persist(
@@ -111,19 +145,19 @@ export const useMcpConfigStore = create<McpConfigStore>()(
setMcpDraft: (draft) => set({ mcpDraft: draft }),
loadMcpConfigs: async () => {
loadMcpConfigs: async (options) => {
const configDirectory = getConfigDirectory();
const cacheKey = getMcpCacheKey(configDirectory);
const now = Date.now();
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedConfigs = get().mcpServers.length > 0;
if (hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
return true;
}
const inFlight = mcpLoadInFlight.get(cacheKey);
if (inFlight) {
if (!options?.force && inFlight) {
return inFlight;
}
@@ -177,6 +211,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
throw new Error(payload?.error || 'Failed to create MCP server');
}
invalidateMcpCache(configDirectory);
if (payload?.requiresReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -184,14 +220,25 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
return true;
await get().loadMcpConfigs({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
await get().loadMcpConfigs();
return true;
await get().loadMcpConfigs({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to create MCP:', error);
return false;
return { ok: false };
} finally {
if (!requiresReload) finishConfigUpdate();
}
@@ -218,6 +265,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
throw new Error(payload?.error || 'Failed to update MCP server');
}
invalidateMcpCache(configDirectory);
if (payload?.requiresReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -225,11 +274,22 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
return true;
await get().loadMcpConfigs({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
await get().loadMcpConfigs();
return true;
await get().loadMcpConfigs({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to update MCP:', error);
throw error;
@@ -254,6 +314,8 @@ export const useMcpConfigStore = create<McpConfigStore>()(
throw new Error(payload?.error || 'Failed to delete MCP server');
}
invalidateMcpCache(configDirectory);
if (payload?.requiresReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -261,17 +323,21 @@ export const useMcpConfigStore = create<McpConfigStore>()(
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
return true;
}
if (get().selectedMcpName === name) {
set({ selectedMcpName: null });
}
await get().loadMcpConfigs();
return true;
await get().loadMcpConfigs({ force: true });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to delete MCP:', error);
return false;
return { ok: false };
} finally {
if (!requiresReload) finishConfigUpdate();
}
@@ -312,6 +378,46 @@ function buildMcpBody(config: Partial<McpDraft>): Record<string, unknown> {
body.environment = envArrayToRecord(config.environment) ?? {};
}
if (config.headers !== undefined) {
body.headers = envArrayToRecord(config.headers) ?? {};
}
if (
config.oauthEnabled !== undefined ||
config.oauthClientId !== undefined ||
config.oauthClientSecret !== undefined ||
config.oauthScope !== undefined ||
config.oauthRedirectUri !== undefined
) {
if (config.oauthEnabled === false) {
body.oauth = false;
} else {
const oauth = {
clientId: trimOptionalString(config.oauthClientId),
clientSecret: trimOptionalString(config.oauthClientSecret),
scope: trimOptionalString(config.oauthScope),
redirectUri: trimOptionalString(config.oauthRedirectUri),
};
if (oauth.clientId || oauth.clientSecret || oauth.scope || oauth.redirectUri) {
body.oauth = oauth;
} else if (config.oauthEnabled) {
body.oauth = {};
} else {
body.oauth = false;
}
}
}
if (config.timeout !== undefined) {
const timeout = Number(config.timeout);
if (Number.isFinite(timeout) && timeout > 0) {
body.timeout = timeout;
} else {
body.timeout = null;
}
}
if (config.enabled !== undefined) {
body.enabled = config.enabled;
}
+124 -1
View File
@@ -5,8 +5,14 @@ import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
export type McpStatusMap = Record<string, McpStatus>;
export type McpRuntimeDiagnostic = {
status: 'failed';
error: string;
};
export type McpRuntimeDiagnosticMap = Record<string, McpRuntimeDiagnostic>;
const EMPTY_STATUS: McpStatusMap = {};
const EMPTY_DIAGNOSTICS: McpRuntimeDiagnosticMap = {};
type McpHealth = {
connected: number;
@@ -47,20 +53,34 @@ type RefreshOptions = {
silent?: boolean;
};
type TestConnectionResult = {
status?: McpStatus;
error?: string;
warning?: string;
};
interface McpStore {
byDirectory: Record<string, McpStatusMap>;
diagnosticsByDirectory: Record<string, McpRuntimeDiagnosticMap>;
loadingKeys: Record<string, boolean>;
lastErrorKeys: Record<string, string | null>;
getStatusForDirectory: (directory?: string | null) => McpStatusMap;
getDiagnosticForDirectory: (directory?: string | null) => McpRuntimeDiagnosticMap;
getErrorForDirectory: (directory?: string | null) => string | null;
refresh: (options?: RefreshOptions) => Promise<void>;
connect: (name: string, directory?: string | null) => Promise<void>;
disconnect: (name: string, directory?: string | null) => Promise<void>;
startAuth: (name: string, directory?: string | null) => Promise<string>;
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
clearAuth: (name: string, directory?: string | null) => Promise<void>;
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
}
export const useMcpStore = create<McpStore>()(
devtools((set, get) => ({
byDirectory: {},
diagnosticsByDirectory: {},
loadingKeys: {},
lastErrorKeys: {},
@@ -69,6 +89,16 @@ export const useMcpStore = create<McpStore>()(
return get().byDirectory[key] ?? EMPTY_STATUS;
},
getDiagnosticForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
return get().diagnosticsByDirectory[key] ?? EMPTY_DIAGNOSTICS;
},
getErrorForDirectory: (directory) => {
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
return get().lastErrorKeys[key] ?? null;
},
refresh: async (options) => {
const directory = normalizeDirectory(options?.directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(directory);
@@ -87,6 +117,12 @@ export const useMcpStore = create<McpStore>()(
set((state) => ({
byDirectory: { ...state.byDirectory, [key]: data },
diagnosticsByDirectory: {
...state.diagnosticsByDirectory,
[key]: Object.fromEntries(
Object.entries(state.diagnosticsByDirectory[key] ?? {}).filter(([name]) => !data[name])
),
},
loadingKeys: { ...state.loadingKeys, [key]: false },
lastErrorKeys: { ...state.lastErrorKeys, [key]: null },
}));
@@ -101,8 +137,23 @@ export const useMcpStore = create<McpStore>()(
connect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(normalized);
const api = getMcpApiClient(normalized);
await api.mcp.connect({ name }, { throwOnError: true });
try {
await api.mcp.connect({ name }, { throwOnError: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
set((state) => ({
diagnosticsByDirectory: {
...state.diagnosticsByDirectory,
[key]: {
...(state.diagnosticsByDirectory[key] ?? {}),
[name]: { status: 'failed', error: message },
},
},
}));
throw error;
}
await get().refresh({ directory: normalized, silent: true });
},
@@ -113,5 +164,77 @@ export const useMcpStore = create<McpStore>()(
await get().refresh({ directory: normalized, silent: true });
},
startAuth: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = getMcpApiClient(normalized);
const result = await api.mcp.auth.start({ name }, { throwOnError: true });
const authorizationUrl = result.data?.authorizationUrl;
if (!authorizationUrl) {
throw new Error('Authorization URL was not returned');
}
return authorizationUrl;
},
completeAuth: async (name, code, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = getMcpApiClient(normalized);
await api.mcp.auth.callback({ name, code }, { throwOnError: true });
await get().refresh({ directory: normalized, silent: true });
},
clearAuth: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = getMcpApiClient(normalized);
await api.mcp.auth.remove({ name }, { throwOnError: true });
await get().refresh({ directory: normalized, silent: true });
},
testConnection: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const key = toKey(normalized);
const api = getMcpApiClient(normalized);
const previousStatus = get().getStatusForDirectory(normalized)[name];
const wasConnected = previousStatus?.status === 'connected';
let errorMessage: string | undefined;
let warningMessage: string | undefined;
try {
await api.mcp.connect({ name }, { throwOnError: true });
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Connection failed';
set((state) => ({
diagnosticsByDirectory: {
...state.diagnosticsByDirectory,
[key]: {
...(state.diagnosticsByDirectory[key] ?? {}),
[name]: { status: 'failed', error: errorMessage ?? 'Connection failed' },
},
},
}));
}
await get().refresh({ directory: normalized, silent: true });
const currentStatus = get().getStatusForDirectory(normalized)[name];
const observedStatus = currentStatus;
if (!wasConnected && currentStatus?.status === 'connected') {
try {
await api.mcp.disconnect({ name }, { throwOnError: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Disconnect failed';
warningMessage = `Connection test succeeded, but cleanup disconnect failed: ${message}`;
}
await get().refresh({ directory: normalized, silent: true });
}
return {
status: observedStatus ?? get().getStatusForDirectory(normalized)[name],
error: errorMessage,
warning: warningMessage,
};
},
}))
);