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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
}))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user