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
+2 -2
View File
@@ -25,13 +25,13 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/language": "6.12.2",
"@codemirror/language-data": "^6.5.2",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.13",
"@codemirror/view": "6.39.13",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+19
View File
@@ -53,6 +53,8 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip';
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
const AboutDialogWrapper: React.FC = () => {
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
@@ -105,6 +107,14 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
};
};
const isMcpOAuthCallbackPath = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
};
const EmbeddedSessionSelectionGate: React.FC<{
embeddedSessionChat: EmbeddedSessionChatConfig | null;
isVSCodeRuntime: boolean;
@@ -192,6 +202,7 @@ function App({ apis }: AppProps) {
const appReadyDispatchedRef = React.useRef(false);
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
React.useEffect(() => {
setStreamPerfEnabled(showMemoryDebug);
@@ -684,6 +695,14 @@ function App({ apis }: AppProps) {
);
}
if (isMcpOAuthCallback) {
return (
<ErrorBoundary>
<McpOAuthCallbackPage />
</ErrorBoundary>
);
}
// VS Code runtime - simplified layout without git/terminal views
if (isVSCodeRuntime) {
// Check if this is the Agent Manager panel
+37 -9
View File
@@ -17,6 +17,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { cn } from '@/lib/utils';
import { useDeviceInfo } from '@/lib/device';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { computeMcpHealth, useMcpStore } from '@/stores/useMcpStore';
import { McpIcon } from '@/components/icons/McpIcon';
@@ -66,6 +67,8 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
const refresh = useMcpStore((state) => state.refresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
const [isSpinning, setIsSpinning] = React.useState(false);
const [busyName, setBusyName] = React.useState<string | null>(null);
@@ -73,14 +76,27 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
void refresh({ directory, silent: true });
}, [refresh, directory]);
React.useEffect(() => {
void loadMcpConfigs({ force: true });
}, [loadMcpConfigs]);
React.useEffect(() => {
if (!active) return;
void refresh({ directory, silent: true });
}, [active, refresh, directory]);
void Promise.all([
refresh({ directory, silent: true }),
loadMcpConfigs({ force: true }),
]);
}, [active, refresh, directory, loadMcpConfigs]);
const sortedNames = React.useMemo(() => {
return Object.keys(status).sort((a, b) => a.localeCompare(b));
}, [status]);
const names = new Set<string>(Object.keys(status));
for (const server of mcpServers) {
if (server?.name) {
names.add(server.name);
}
}
return Array.from(names).sort((a, b) => a.localeCompare(b));
}, [mcpServers, status]);
const handleRefresh = React.useCallback((e?: React.MouseEvent) => {
e?.preventDefault();
@@ -195,6 +211,8 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
const refresh = useMcpStore((state) => state.refresh);
const connect = useMcpStore((state) => state.connect);
const disconnect = useMcpStore((state) => state.disconnect);
const mcpServers = useMcpConfigStore((state) => state.mcpServers);
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
const handleDropdownOpenChange = React.useCallback((isOpen: boolean) => {
if (!isOpen) {
@@ -219,19 +237,29 @@ export const McpDropdown: React.FC<McpDropdownProps> = ({ headerIconButtonClass
// Fetch on mount and when directory changes
React.useEffect(() => {
void refresh({ directory, silent: true });
}, [refresh, directory]);
void loadMcpConfigs({ force: true });
}, [refresh, directory, loadMcpConfigs]);
// Refresh when dropdown opens
React.useEffect(() => {
if (!open) return;
void refresh({ directory, silent: true });
}, [open, refresh, directory]);
void Promise.all([
refresh({ directory, silent: true }),
loadMcpConfigs({ force: true }),
]);
}, [open, refresh, directory, loadMcpConfigs]);
const health = React.useMemo(() => computeMcpHealth(status), [status]);
const sortedNames = React.useMemo(() => {
return Object.keys(status).sort((a, b) => a.localeCompare(b));
}, [status]);
const names = new Set<string>(Object.keys(status));
for (const server of mcpServers) {
if (server?.name) {
names.add(server.name);
}
}
return Array.from(names).sort((a, b) => a.localeCompare(b));
}, [mcpServers, status]);
const handleRefresh = React.useCallback((e?: React.MouseEvent) => {
e?.preventDefault();
@@ -0,0 +1,124 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useMcpStore } from '@/stores/useMcpStore';
import { parseMcpOAuthCallbackContext, parseMcpOAuthCallbackStateKey } from '@/components/sections/mcp/mcpOAuth';
const parseQueryParam = (params: URLSearchParams, key: string): string | null => {
const value = params.get(key);
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed || null;
};
const normalizeMcpAuthErrorMessage = (error: unknown, fallback: string): string => {
const message = error instanceof Error ? error.message : fallback;
if (/oauth state required/i.test(message)) {
return 'Authorization session expired or was cleared during reload. Return to OpenChamber and click Authorize again.';
}
return message;
};
export const McpOAuthCallbackPage: React.FC = () => {
const completeAuth = useMcpStore((state) => state.completeAuth);
const [status, setStatus] = React.useState<'working' | 'success' | 'error'>('working');
const [message, setMessage] = React.useState('Completing MCP authorization...');
React.useEffect(() => {
if (typeof window === 'undefined') {
setStatus('error');
setMessage('Browser context unavailable.');
return;
}
const params = new URLSearchParams(window.location.search);
const code = parseQueryParam(params, 'code');
const callbackContext = parseMcpOAuthCallbackContext(params);
const callbackStateKey = parseMcpOAuthCallbackStateKey(params);
const error = parseQueryParam(params, 'error');
const errorDescription = parseQueryParam(params, 'error_description');
if (error) {
if (callbackStateKey) {
void fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(errorDescription ?? error);
return;
}
void (async () => {
try {
if (!code) {
throw new Error('Missing OAuth authorization code. Start authorization again from MCP Settings or paste the returned code into OpenChamber manually.');
}
let pendingContext = callbackContext;
if (!pendingContext && callbackStateKey) {
const response = await fetch(`/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()) {
pendingContext = {
name: payload.name.trim(),
directory: typeof payload.directory === 'string' && payload.directory.trim() ? payload.directory.trim() : null,
};
}
}
}
if (!pendingContext?.name) {
throw new Error('Authorization session details were not available. Start authorization again from MCP Settings or paste the returned code into OpenChamber manually.');
}
await completeAuth(pendingContext.name, code, pendingContext.directory);
if (callbackStateKey) {
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('success');
setMessage('Authorization completed. You can close this tab and return to OpenChamber.');
} catch (authError) {
if (callbackStateKey) {
await fetch(`/api/mcp/auth/pending?state=${encodeURIComponent(callbackStateKey)}`, { method: 'DELETE' }).catch(() => undefined);
}
setStatus('error');
setMessage(normalizeMcpAuthErrorMessage(authError, 'Failed to complete MCP authorization.'));
}
})();
}, [completeAuth]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6 py-12 text-foreground">
<div className="w-full max-w-xl rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-8 shadow-sm">
<div className="space-y-3 text-center">
<div
className={status === 'error' ? 'text-[var(--status-error)]' : status === 'success' ? 'text-[var(--status-success)]' : 'text-[var(--status-info)]'}
>
<h1 className="typography-hero font-semibold">
{status === 'working' ? 'Completing Authorization' : status === 'success' ? 'Authorization Complete' : 'Authorization Failed'}
</h1>
</div>
<p className="typography-body text-muted-foreground">{message}</p>
</div>
{status !== 'working' && (
<div className="mt-8 flex justify-center">
<Button
type="button"
onClick={() => {
if (typeof window === 'undefined') {
return;
}
window.location.replace('/');
}}
>
Return to OpenChamber
</Button>
</div>
)}
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine } from '@remixicon/react';
import { RiAddLine, RiDeleteBinLine, RiMore2Line, RiPlugLine, RiRefreshLine, RiServerLine, RiGlobalLine } from '@remixicon/react';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -66,10 +66,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const refreshStatus = useMcpStore((state) => state.refresh);
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
const [deleteTarget, setDeleteTarget] = React.useState<McpServerConfig | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
const [openMenuMcp, setOpenMenuMcp] = React.useState<string | null>(null);
const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false);
const projectServers = React.useMemo(
() => mcpServers.filter((server) => server.scope === 'project'),
@@ -84,6 +87,25 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
void loadMcpConfigs();
}, [loadMcpConfigs]);
const handleRefresh = React.useCallback(() => {
if (isRefreshingStatus) return;
setIsRefreshingStatus(true);
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
Promise.all([
refreshStatus({ directory: currentDirectory, silent: true }),
minSpinPromise,
]).then(() => {
const error = getErrorForDirectory(currentDirectory);
if (error) {
toast.error(error);
}
}).finally(() => {
setIsRefreshingStatus(false);
});
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
const handleCreateNew = () => {
const baseName = 'new-mcp-server';
let newName = baseName;
@@ -100,6 +122,13 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
command: [],
url: '',
environment: [],
headers: [],
oauthEnabled: true,
oauthClientId: '',
oauthClientSecret: '',
oauthScope: '',
oauthRedirectUri: '',
timeout: '',
enabled: true,
};
setMcpDraft(draft);
@@ -110,9 +139,15 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const success = await deleteMcp(deleteTarget.name);
if (success) {
toast.success(`MCP server "${deleteTarget.name}" deleted`);
const result = await deleteMcp(deleteTarget.name);
if (result.ok) {
if (result.reloadFailed) {
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {
description: result.warning || 'Refresh the MCP list if the UI looks stale.',
});
} else {
toast.success(result.message || `MCP server "${deleteTarget.name}" deleted`);
}
} else {
toast.error('Failed to delete MCP server');
}
@@ -123,7 +158,19 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">MCP Servers</h2>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">MCP Servers</h2>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
disabled={isRefreshingStatus}
onClick={handleRefresh}
aria-label="Refresh MCP status"
title="Refresh MCP status"
>
<RiRefreshLine className={cn('h-4 w-4', isRefreshingStatus && 'animate-spin')} />
</button>
</div>
<SettingsProjectSelector className="mb-3" />
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">
@@ -184,8 +231,12 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
{server.type === 'local' ? (
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
) : (
<RiGlobalLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
)}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
@@ -254,8 +305,12 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
<div className="flex items-center gap-2">
<StatusDot tone={tone} enabled={server.enabled} />
<span className="typography-ui-label font-normal truncate text-foreground">{server.name}</span>
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
{server.type}
<span title={server.type === 'local' ? 'Local server' : 'Remote server'}>
{server.type === 'local' ? (
<RiServerLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
) : (
<RiGlobalLine className="h-3 w-3 text-muted-foreground/60 flex-shrink-0" />
)}
</span>
</div>
<div className="typography-micro text-muted-foreground/60 truncate leading-tight pl-4">
@@ -0,0 +1,339 @@
import type { McpDraft } from '@/stores/useMcpConfigStore';
export interface ImportedMcpResult {
readonly ok: true;
readonly name?: string;
readonly type: 'local' | 'remote';
readonly command: string[];
readonly url: string;
readonly environment: Array<{ key: string; value: string }>;
readonly headers: Array<{ key: string; value: string }>;
readonly oauthEnabled: boolean;
readonly oauthClientId: string;
readonly oauthClientSecret: string;
readonly oauthScope: string;
readonly oauthRedirectUri: string;
readonly timeout: string;
readonly enabled: boolean;
}
export type ImportedMcpError =
| { readonly ok: false; readonly error: string }
| { readonly ok: false; readonly error: string; readonly parsed: unknown };
export type ImportedMcpOutcome = ImportedMcpResult | ImportedMcpError;
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string');
}
function buildError(message: string, parsed?: unknown): ImportedMcpError {
return parsed !== undefined
? { ok: false, error: message, parsed }
: { ok: false, error: message };
}
function buildResult(
name: string | undefined,
type: 'local' | 'remote',
raw: Record<string, unknown>,
): ImportedMcpResult {
const command: string[] = buildCommand(raw);
const url = typeof raw.url === 'string' ? raw.url.trim() : '';
const environment = buildEnv(raw, 'env', 'environment');
const headers = buildEnv(raw, 'headers');
const oauthEnabled = buildOAuthEnabled(raw);
const oauthClientId = typeof raw.oauth === 'object' && raw.oauth !== null
? String((raw.oauth as Record<string, unknown>).clientId ?? '').trim()
: '';
const oauthClientSecret = typeof raw.oauth === 'object' && raw.oauth !== null
? String((raw.oauth as Record<string, unknown>).clientSecret ?? '').trim()
: '';
const oauthScope = typeof raw.oauth === 'object' && raw.oauth !== null
? String((raw.oauth as Record<string, unknown>).scope ?? '').trim()
: '';
const oauthRedirectUri = typeof raw.oauth === 'object' && raw.oauth !== null
? String((raw.oauth as Record<string, unknown>).redirectUri ?? '').trim()
: '';
const timeout = buildTimeout(raw);
const enabled = buildEnabled(raw);
return {
ok: true,
name,
type,
command,
url,
environment,
headers,
oauthEnabled,
oauthClientId,
oauthClientSecret,
oauthScope,
oauthRedirectUri,
timeout,
enabled,
};
}
function buildCommand(raw: Record<string, unknown>): string[] {
const cmd = raw.command;
const args = raw.args;
if (stringArray(cmd) && stringArray(args)) {
return [...cmd, ...args];
}
if (stringArray(cmd)) {
return cmd;
}
if (typeof cmd === 'string' && cmd.trim()) {
const parts = cmd.trim().split(/\s+/);
if (stringArray(args)) {
return [...parts, ...args];
}
return parts;
}
if (stringArray(args)) {
return args;
}
return [];
}
function buildEnv(
raw: Record<string, unknown>,
...keys: string[]
): Array<{ key: string; value: string }> {
for (const key of keys) {
const val = raw[key];
if (!isObject(val)) continue;
const entries = Object.entries(val as Record<string, unknown>).filter(
([k, v]) => k && typeof v === 'string',
);
if (entries.length > 0) {
return entries.map(([k, v]) => ({ key: k, value: String(v) }));
}
}
return [];
}
function buildOAuthEnabled(raw: Record<string, unknown>): boolean {
if (raw.oauth === false || raw.oauth === null || raw.oauth === undefined) {
return false;
}
if (!isObject(raw.oauth)) {
return false;
}
const oauth = raw.oauth as Record<string, unknown>;
return !!(
(typeof oauth.clientId === 'string' && oauth.clientId.trim()) ||
(typeof oauth.clientSecret === 'string' && oauth.clientSecret.trim()) ||
(typeof oauth.scope === 'string' && oauth.scope.trim()) ||
(typeof oauth.redirectUri === 'string' && oauth.redirectUri.trim())
);
}
function buildTimeout(raw: Record<string, unknown>): string {
const t = raw.timeout;
if (typeof t === 'number' && Number.isFinite(t) && t > 0) {
return String(Math.floor(t));
}
if (typeof t === 'string' && t.trim()) {
const n = Number(t);
if (Number.isFinite(n) && n > 0) {
return String(Math.floor(n));
}
}
return '';
}
function buildEnabled(raw: Record<string, unknown>): boolean {
if ('disabled' in raw && raw.disabled === true) {
return false;
}
if ('enabled' in raw) {
return Boolean(raw.enabled);
}
return true;
}
/**
* Extract a single named server entry from a parsed JSON object.
* Returns null if the shape does not contain exactly one identifiable server.
*/
function extractSingleServer(
obj: Record<string, unknown>,
): { name: string; entry: Record<string, unknown> } | null {
const mcpServers = obj.mcpServers;
if (isObject(mcpServers)) {
const keys = Object.keys(mcpServers);
if (keys.length === 1) {
const name = keys[0]!;
const entry = mcpServers[name];
if (isObject(entry)) {
return { name, entry };
}
}
if (keys.length > 1) {
return null;
}
}
const serverKeys = Object.keys(obj).filter((k) => {
const v = obj[k];
return k !== 'mcpServers' && isObject(v);
});
if (serverKeys.length === 1) {
const name = serverKeys[0]!;
const entry = obj[name] as Record<string, unknown>;
if (isServerConfig(entry)) {
return { name, entry };
}
}
return null;
}
function isServerConfig(val: Record<string, unknown>): boolean {
return (
val.type === 'local' ||
val.type === 'remote' ||
Array.isArray(val.command) ||
typeof val.url === 'string' ||
Array.isArray(val.args)
);
}
/**
* Parse a raw JSON string as an MCP server snippet and return normalized result
* or a structured error. Does not mutate the current draft — caller applies
* the result to form state.
*
* Supported shapes:
* { "mcpServers": { "name": { ... } } }
* { "name": { ... } }
* { ...serverConfig }
*/
export function parseImportedMcpSnippet(
raw: string,
options?: { fallbackName?: string },
): ImportedMcpOutcome {
let parsed: unknown;
try {
const trimmed = raw.trim();
if (!trimmed) {
return buildError('No JSON content provided');
}
parsed = JSON.parse(trimmed);
} catch (err) {
return buildError(
err instanceof Error ? `Invalid JSON: ${err.message}` : 'Invalid JSON',
);
}
if (!isObject(parsed)) {
return buildError('Expected a JSON object, not an array or primitive');
}
const obj = parsed as Record<string, unknown>;
// Detect single named entry inside { "mcpServers": { "name": { ... } } }
const mcpServers = obj.mcpServers;
if (isObject(mcpServers)) {
const keys = Object.keys(mcpServers);
if (keys.length === 0) {
return buildError('mcpServers object is empty', parsed);
}
if (keys.length > 1) {
return buildError(
'Paste one server at a time. Found ' +
keys.length +
' servers in mcpServers',
parsed,
);
}
const serverName = keys[0]!;
const entry = mcpServers[serverName];
if (!isObject(entry)) {
return buildError('Server entry is not a valid object', parsed);
}
return buildResult(serverName, inferType(entry as Record<string, unknown>), entry as Record<string, unknown>);
}
// Detect single named entry { "serverName": { ... } }
const single = extractSingleServer(obj);
if (single) {
return buildResult(
single.name,
inferType(single.entry),
single.entry,
);
}
// Treat top-level as a bare server config
if (isServerConfig(obj)) {
const type = inferType(obj);
const name = typeof obj.name === 'string' && obj.name.trim()
? obj.name.trim()
: options?.fallbackName;
return buildResult(name, type, obj);
}
return buildError(
'No recognizable MCP server configuration found in JSON',
parsed,
);
}
function inferType(entry: Record<string, unknown>): 'local' | 'remote' {
if (entry.type === 'remote') return 'remote';
if (entry.type === 'local') return 'local';
if (typeof entry.url === 'string' && entry.url.trim()) return 'remote';
if (Array.isArray(entry.command) || typeof entry.command === 'string') return 'local';
if (Array.isArray(entry.args)) return 'local';
return 'local';
}
/**
* Apply an imported result to a new or existing McpDraft.
* All fields from the result override the draft, but fields only
* relevant to the opposite transport type are cleared.
*/
export function applyImportedMcpToDraft(
result: ImportedMcpResult,
currentDraft: Partial<McpDraft> & { name?: string },
options?: { isNewServer?: boolean },
): Partial<McpDraft> & { name: string } {
const isNew = options?.isNewServer ?? false;
const importedName = result.name;
const name = isNew && importedName ? importedName : currentDraft.name ?? '';
const draft: Partial<McpDraft> & { name: string } = {
...currentDraft,
name,
type: result.type,
command: result.type === 'local' ? result.command : [],
url: result.type === 'remote' ? result.url : '',
environment: result.environment,
headers: result.type === 'remote' ? result.headers : [],
oauthEnabled: result.type === 'remote' ? result.oauthEnabled : false,
oauthClientId: result.type === 'remote' ? result.oauthClientId : '',
oauthClientSecret: result.type === 'remote' ? result.oauthClientSecret : '',
oauthScope: result.type === 'remote' ? result.oauthScope : '',
oauthRedirectUri: result.type === 'remote' ? result.oauthRedirectUri : '',
timeout: result.type === 'remote' ? result.timeout : '',
enabled: result.enabled,
};
return draft;
}
@@ -0,0 +1,82 @@
export const MCP_OAUTH_CALLBACK_PATH = '/mcp/oauth/callback';
type McpOAuthStatePayload = {
v: 1;
n: string;
d: string | null;
};
const decodeBase64Url = (value: string): string | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
const binary = window.atob(normalized + padding);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
};
export const parseMcpOAuthCallbackContext = (params: URLSearchParams): {
name: string;
directory: string | null;
} | null => {
const stateContext = parseMcpOAuthState(params.get('state'));
if (stateContext) {
return stateContext;
}
const server = params.get('server');
if (typeof server !== 'string' || !server.trim()) {
return null;
}
const directory = params.get('directory');
return {
name: server.trim(),
directory: typeof directory === 'string' && directory.trim() ? directory.trim() : null,
};
};
export const parseMcpOAuthCallbackStateKey = (params: URLSearchParams): string | null => {
const rawState = params.get('state');
if (typeof rawState !== 'string') {
return null;
}
const trimmed = rawState.trim();
return trimmed || null;
};
export const parseMcpOAuthState = (raw: string | null | undefined): {
name: string;
directory: string | null;
} | null => {
if (typeof raw !== 'string' || !raw.trim()) {
return null;
}
const decoded = decodeBase64Url(raw.trim());
if (!decoded) {
return null;
}
try {
const payload = JSON.parse(decoded) as Partial<McpOAuthStatePayload>;
if (payload?.v !== 1 || typeof payload.n !== 'string' || !payload.n.trim()) {
return null;
}
return {
name: payload.n.trim(),
directory: typeof payload.d === 'string' && payload.d.trim() ? payload.d.trim() : null,
};
} catch {
return null;
}
};
+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,
};
},
}))
);
@@ -20,6 +20,29 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
deleteMcpConfig,
} = dependencies;
const completeMcpMutation = async (res, action, name, applyChange) => {
applyChange();
try {
await refreshOpenCodeAfterConfigChange(`mcp ${action}`);
return res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" ${action}d. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error(`[API:MCP ${action}] Reload failed after config write:`, error);
return res.json({
success: true,
requiresReload: false,
reloadFailed: true,
message: `MCP server "${name}" ${action}d, but OpenCode reload failed.`,
warning: error.message || 'OpenCode reload failed after the MCP configuration changed',
});
}
};
app.get('/api/config/agents/:name', async (req, res) => {
try {
const agentName = req.params.name;
@@ -187,14 +210,8 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
}
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
createMcpConfig(name, config, directory, scope);
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" created. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
await completeMcpMutation(res, 'create', name, () => {
createMcpConfig(name, config, directory, scope);
});
} catch (error) {
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
@@ -212,17 +229,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
}
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
updateMcpConfig(name, updates, directory);
await refreshOpenCodeAfterConfigChange('mcp update');
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" updated. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
await completeMcpMutation(res, 'update', name, () => {
updateMcpConfig(name, updates, directory);
});
} catch (error) {
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
if (error?.message === `MCP server "${req.params.name}" not found`) {
return res.status(404).json({ error: error.message });
}
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
}
});
@@ -236,14 +250,8 @@ export const registerConfigEntityRoutes = (app, dependencies) => {
}
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
deleteMcpConfig(name, directory);
await refreshOpenCodeAfterConfigChange('mcp deletion');
res.json({
success: true,
requiresReload: true,
message: `MCP server "${name}" deleted. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
await completeMcpMutation(res, 'delete', name, () => {
deleteMcpConfig(name, directory);
});
} catch (error) {
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
+74 -2
View File
@@ -118,6 +118,11 @@ function createMcpConfig(name, mcpConfig, workingDirectory, scope) {
function updateMcpConfig(name, updates, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
if (!source.exists) {
throw new Error(`MCP server "${name}" not found`);
}
const targetPath = source.path || CONFIG_FILE;
const config = source.config || (fs.existsSync(targetPath) ? readConfigFile(targetPath) : {});
@@ -125,7 +130,7 @@ function updateMcpConfig(name, updates, workingDirectory) {
config.mcp = {};
}
const existing = config.mcp[name] ?? {};
const existing = config.mcp[name];
const { name: _ignoredName, ...updateData } = updates;
config.mcp[name] = buildMcpEntry({ ...existing, ...updateData });
@@ -161,7 +166,12 @@ function deleteMcpConfig(name, workingDirectory) {
* Build a clean MCP entry object, omitting undefined/null values
*/
function buildMcpEntry(data) {
const entry = {};
const entry = (data && typeof data === 'object' && !Array.isArray(data))
? { ...data }
: {};
delete entry.name;
delete entry.scope;
// type is required
entry.type = data.type === 'remote' ? 'remote' : 'local';
@@ -170,11 +180,69 @@ function buildMcpEntry(data) {
// command must be a non-empty array of strings
if (Array.isArray(data.command) && data.command.length > 0) {
entry.command = data.command.map(String);
} else {
delete entry.command;
}
delete entry.url;
delete entry.headers;
delete entry.oauth;
delete entry.timeout;
} else {
// remote: url required
if (data.url && typeof data.url === 'string') {
entry.url = data.url.trim();
} else {
delete entry.url;
}
delete entry.command;
if (data.headers && typeof data.headers === 'object' && !Array.isArray(data.headers)) {
const cleaned = {};
for (const [k, v] of Object.entries(data.headers)) {
if (k && v !== undefined && v !== null) {
cleaned[k] = String(v);
}
}
if (Object.keys(cleaned).length > 0) {
entry.headers = cleaned;
} else {
delete entry.headers;
}
} else if (data.headers === undefined) {
delete entry.headers;
}
if (data.oauth === false) {
entry.oauth = false;
} else if (data.oauth && typeof data.oauth === 'object' && !Array.isArray(data.oauth)) {
const oauth = {};
if (typeof data.oauth.clientId === 'string' && data.oauth.clientId.trim()) {
oauth.clientId = data.oauth.clientId.trim();
}
if (typeof data.oauth.clientSecret === 'string' && data.oauth.clientSecret.trim()) {
oauth.clientSecret = data.oauth.clientSecret.trim();
}
if (typeof data.oauth.scope === 'string' && data.oauth.scope.trim()) {
oauth.scope = data.oauth.scope.trim();
}
if (typeof data.oauth.redirectUri === 'string' && data.oauth.redirectUri.trim()) {
oauth.redirectUri = data.oauth.redirectUri.trim();
}
if (Object.keys(oauth).length > 0) {
entry.oauth = oauth;
} else {
delete entry.oauth;
}
} else if (data.oauth === undefined) {
delete entry.oauth;
}
if (typeof data.timeout === 'number' && Number.isFinite(data.timeout) && data.timeout > 0) {
entry.timeout = data.timeout;
} else if (data.timeout === undefined || data.timeout === null || data.timeout === '') {
delete entry.timeout;
}
}
@@ -188,7 +256,11 @@ function buildMcpEntry(data) {
}
if (Object.keys(cleaned).length > 0) {
entry.environment = cleaned;
} else {
delete entry.environment;
}
} else if (data.environment === undefined) {
delete entry.environment;
}
// enabled defaults to true
@@ -18,6 +18,8 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
} = dependencies;
let authLibrary = null;
const pendingMcpAuthContextByState = new Map();
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
@@ -25,6 +27,24 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
return authLibrary;
};
const normalizePendingString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed || null;
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
pendingMcpAuthContextByState.delete(state);
}
}
};
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
@@ -59,6 +79,76 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
}
});
app.post('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(req.body?.state);
if (!state) {
return res.json({ success: true, context: null });
}
const name = normalizePendingString(req.body?.name);
if (!name) {
return res.status(400).json({ error: 'MCP server name is required' });
}
const entry = {
name,
directory: normalizePendingString(req.body?.directory),
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
};
pendingMcpAuthContextByState.set(state, entry);
return res.json({
success: true,
context: {
name: entry.name,
directory: entry.directory,
},
});
} catch (error) {
console.error('Failed to store pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
}
});
app.get('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json(null);
}
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
if (!pendingMcpAuthContext) {
return res.status(404).json({ error: 'No pending MCP auth context' });
}
return res.json(pendingMcpAuthContext);
} catch (error) {
console.error('Failed to read pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
}
});
app.delete('/api/mcp/auth/pending', async (req, res) => {
try {
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json({ success: true });
}
pendingMcpAuthContextByState.delete(state);
return res.json({ success: true });
} catch (error) {
console.error('Failed to clear pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;