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