fix(mcp): add availability hint for failed servers in list panel

Show 'failed (may be unavailable)' next to disconnected servers instead of plain 'failed'

Add footer note explaining unavailable servers cannot be used until reconnected

Signed-off-by: 林凯90331 <90331@sangfor.com>

Co-authored-by: CoStrict <zgsm@sangfor.com.cn>
This commit is contained in:
林凯90331 2026-05-15 16:55:02 +08:00
parent b0f10760fe
commit c45544c066

View File

@ -1,72 +1,67 @@
import figures from 'figures'
import React, { useCallback, useState } from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { Box, color, Link, Text, useTheme } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import type { ConfigScope } from '../../services/mcp/types.js'
import { describeMcpConfigFilePath } from '../../services/mcp/utils.js'
import { isDebugMode } from '../../utils/debug.js'
import { plural } from '../../utils/stringUtils.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
import { McpParsingWarnings } from './McpParsingWarnings.js'
import type { AgentMcpServerInfo, ServerInfo } from './types.js'
import figures from 'figures';
import React, { useCallback, useState } from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import { Box, color, Link, Text, useTheme } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import type { ConfigScope } from '../../services/mcp/types.js';
import { describeMcpConfigFilePath } from '../../services/mcp/utils.js';
import { isDebugMode } from '../../utils/debug.js';
import { plural } from '../../utils/stringUtils.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink';
import { McpParsingWarnings } from './McpParsingWarnings.js';
import type { AgentMcpServerInfo, ServerInfo } from './types.js';
type Props = {
servers: ServerInfo[]
agentServers?: AgentMcpServerInfo[]
onSelectServer: (server: ServerInfo) => void
onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void
onComplete: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
defaultTab?: string
}
servers: ServerInfo[];
agentServers?: AgentMcpServerInfo[];
onSelectServer: (server: ServerInfo) => void;
onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void;
onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void;
defaultTab?: string;
};
type SelectableItem =
| { type: 'server'; server: ServerInfo }
| { type: 'agent-server'; agentServer: AgentMcpServerInfo }
| { type: 'agent-server'; agentServer: AgentMcpServerInfo };
// Define scope order for display (constant, outside component)
// 'dynamic' (built-in) is rendered separately at the end
const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise']
const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise'];
// Get scope heading parts (label is bold, path is grey)
function getScopeHeading(scope: ConfigScope): { label: string; path?: string } {
switch (scope) {
case 'project':
return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) }
return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) };
case 'user':
return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) }
return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) };
case 'local':
return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) }
return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) };
case 'enterprise':
return { label: 'Enterprise MCPs' }
return { label: 'Enterprise MCPs' };
case 'dynamic':
return { label: 'Built-in MCPs', path: 'always available' }
return { label: 'Built-in MCPs', path: 'always available' };
default:
return { label: scope }
return { label: scope };
}
}
// Group servers by scope
function groupServersByScope(
serverList: ServerInfo[],
): Map<ConfigScope, ServerInfo[]> {
const groups = new Map<ConfigScope, ServerInfo[]>()
function groupServersByScope(serverList: ServerInfo[]): Map<ConfigScope, ServerInfo[]> {
const groups = new Map<ConfigScope, ServerInfo[]>();
for (const server of serverList) {
const scope = server.scope
const scope = server.scope;
if (!groups.has(scope)) {
groups.set(scope, [])
groups.set(scope, []);
}
groups.get(scope)!.push(server)
groups.get(scope)!.push(server);
}
// Sort servers within each group alphabetically
for (const [, groupServers] of groups) {
groupServers.sort((a, b) => a.name.localeCompare(b.name))
groupServers.sort((a, b) => a.name.localeCompare(b.name));
}
return groups
return groups;
}
export function MCPListPanel({
@ -76,177 +71,151 @@ export function MCPListPanel({
onSelectAgentServer,
onComplete,
}: Props): React.ReactNode {
const [theme] = useTheme()
const [selectedIndex, setSelectedIndex] = useState(0)
const [theme] = useTheme();
const [selectedIndex, setSelectedIndex] = useState(0);
// Non-claudeai servers grouped by scope
const serversByScope = React.useMemo(() => {
const regularServers = servers.filter(
s => s.client.config.type !== 'claudeai-proxy',
)
return groupServersByScope(regularServers)
}, [servers])
const regularServers = servers.filter(s => s.client.config.type !== 'claudeai-proxy');
return groupServersByScope(regularServers);
}, [servers]);
const claudeAiServers = React.useMemo(
() =>
servers
.filter(s => s.client.config.type === 'claudeai-proxy')
.sort((a, b) => a.name.localeCompare(b.name)),
() => servers.filter(s => s.client.config.type === 'claudeai-proxy').sort((a, b) => a.name.localeCompare(b.name)),
[servers],
)
);
// Built-in (dynamic) servers - rendered last
const dynamicServers = React.useMemo(
() =>
(serversByScope.get('dynamic') ?? []).sort((a, b) =>
a.name.localeCompare(b.name),
),
() => (serversByScope.get('dynamic') ?? []).sort((a, b) => a.name.localeCompare(b.name)),
[serversByScope],
)
);
// Pre-compute dynamic heading for render
const dynamicHeading = getScopeHeading('dynamic')
const dynamicHeading = getScopeHeading('dynamic');
// Build flat list of selectable items in display order
const selectableItems = React.useMemo(() => {
const items: SelectableItem[] = []
const items: SelectableItem[] = [];
for (const scope of SCOPE_ORDER) {
const scopeServers = serversByScope.get(scope) ?? []
const scopeServers = serversByScope.get(scope) ?? [];
for (const server of scopeServers) {
items.push({ type: 'server', server })
items.push({ type: 'server', server });
}
}
for (const server of claudeAiServers) {
items.push({ type: 'server', server })
items.push({ type: 'server', server });
}
for (const agentServer of agentServers) {
items.push({ type: 'agent-server', agentServer })
items.push({ type: 'agent-server', agentServer });
}
// Dynamic (built-in) servers come last
for (const server of dynamicServers) {
items.push({ type: 'server', server })
items.push({ type: 'server', server });
}
return items
}, [serversByScope, claudeAiServers, agentServers, dynamicServers])
return items;
}, [serversByScope, claudeAiServers, agentServers, dynamicServers]);
const handleCancel = useCallback((): void => {
onComplete('MCP dialog dismissed', {
display: 'system',
})
}, [onComplete])
});
}, [onComplete]);
const handleSelect = useCallback((): void => {
const item = selectableItems[selectedIndex]
if (!item) return
const item = selectableItems[selectedIndex];
if (!item) return;
if (item.type === 'server') {
onSelectServer(item.server)
onSelectServer(item.server);
} else if (item.type === 'agent-server' && onSelectAgentServer) {
onSelectAgentServer(item.agentServer)
onSelectAgentServer(item.agentServer);
}
}, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer])
}, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]);
// Use configurable keybindings for navigation and selection
useKeybindings(
{
'confirm:previous': () =>
setSelectedIndex(prev =>
prev === 0 ? selectableItems.length - 1 : prev - 1,
),
'confirm:next': () =>
setSelectedIndex(prev =>
prev === selectableItems.length - 1 ? 0 : prev + 1,
),
'confirm:previous': () => setSelectedIndex(prev => (prev === 0 ? selectableItems.length - 1 : prev - 1)),
'confirm:next': () => setSelectedIndex(prev => (prev === selectableItems.length - 1 ? 0 : prev + 1)),
'confirm:yes': handleSelect,
'confirm:no': handleCancel,
},
{ context: 'Confirmation' },
)
);
// Build index lookup for each server
const getServerIndex = (server: ServerInfo): number => {
return selectableItems.findIndex(
item => item.type === 'server' && item.server === server,
)
}
return selectableItems.findIndex(item => item.type === 'server' && item.server === server);
};
const getAgentServerIndex = (agentServer: AgentMcpServerInfo): number => {
return selectableItems.findIndex(
item => item.type === 'agent-server' && item.agentServer === agentServer,
)
}
return selectableItems.findIndex(item => item.type === 'agent-server' && item.agentServer === agentServer);
};
const debugMode = isDebugMode()
const hasFailedClients = servers.some(s => s.client.type === 'failed')
const debugMode = isDebugMode();
const hasFailedClients = servers.some(s => s.client.type === 'failed');
if (servers.length === 0 && agentServers.length === 0) {
return null
return null;
}
const renderServerItem = (server: ServerInfo): React.ReactNode => {
const index = getServerIndex(server)
const isSelected = selectedIndex === index
let statusIcon = ''
let statusText = ''
const index = getServerIndex(server);
const isSelected = selectedIndex === index;
let statusIcon = '';
let statusText = '';
if (server.client.type === 'disabled') {
statusIcon = color('inactive', theme)(figures.radioOff)
statusText = 'disabled'
statusIcon = color('inactive', theme)(figures.radioOff);
statusText = 'disabled';
} else if (server.client.type === 'connected') {
statusIcon = color('success', theme)(figures.tick)
statusText = 'connected'
statusIcon = color('success', theme)(figures.tick);
statusText = 'connected';
} else if (server.client.type === 'pending') {
statusIcon = color('inactive', theme)(figures.radioOff)
const { reconnectAttempt, maxReconnectAttempts } = server.client
statusIcon = color('inactive', theme)(figures.radioOff);
const { reconnectAttempt, maxReconnectAttempts } = server.client;
if (reconnectAttempt && maxReconnectAttempts) {
statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`
statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`;
} else {
statusText = 'connecting…'
statusText = 'connecting…';
}
} else if (server.client.type === 'needs-auth') {
statusIcon = color('warning', theme)(figures.triangleUpOutline)
statusText = 'needs authentication'
statusIcon = color('warning', theme)(figures.triangleUpOutline);
statusText = 'needs authentication';
} else {
statusIcon = color('error', theme)(figures.cross)
statusText = 'failed'
statusIcon = color('error', theme)(figures.cross);
statusText = 'failed (may be unavailable)';
}
return (
<Box key={`${server.name}-${index}`}>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{server.name}</Text>
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
}
);
};
const renderAgentServerItem = (
agentServer: AgentMcpServerInfo,
): React.ReactNode => {
const index = getAgentServerIndex(agentServer)
const isSelected = selectedIndex === index
const renderAgentServerItem = (agentServer: AgentMcpServerInfo): React.ReactNode => {
const index = getAgentServerIndex(agentServer);
const isSelected = selectedIndex === index;
const statusIcon = agentServer.needsAuth
? color('warning', theme)(figures.triangleUpOutline)
: color('inactive', theme)(figures.radioOff)
const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only'
: color('inactive', theme)(figures.radioOff);
const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only';
return (
<Box key={`agent-${agentServer.name}-${index}`}>
<Text color={isSelected ? 'suggestion' : undefined}>
{isSelected ? `${figures.pointer} ` : ' '}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>
{agentServer.name}
</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{isSelected ? `${figures.pointer} ` : ' '}</Text>
<Text color={isSelected ? 'suggestion' : undefined}>{agentServer.name}</Text>
<Text dimColor={!isSelected}> · {statusIcon} </Text>
<Text dimColor={!isSelected}>{statusText}</Text>
</Box>
)
}
);
};
const totalServers = servers.length + agentServers.length
const totalServers = servers.length + agentServers.length;
return (
<Box flexDirection="column">
@ -261,9 +230,9 @@ export function MCPListPanel({
<Box flexDirection="column">
{/* Regular servers grouped by scope */}
{SCOPE_ORDER.map(scope => {
const scopeServers = serversByScope.get(scope)
if (!scopeServers || scopeServers.length === 0) return null
const heading = getScopeHeading(scope)
const scopeServers = serversByScope.get(scope);
if (!scopeServers || scopeServers.length === 0) return null;
const heading = getScopeHeading(scope);
return (
<Box key={scope} flexDirection="column" marginBottom={1}>
<Box paddingLeft={2}>
@ -272,7 +241,7 @@ export function MCPListPanel({
</Box>
{scopeServers.map(server => renderServerItem(server))}
</Box>
)
);
})}
{/* Claude.ai servers section */}
@ -292,18 +261,16 @@ export function MCPListPanel({
<Text bold>Agent MCPs</Text>
</Box>
{/* Group servers by source agent */}
{[...new Set(agentServers.flatMap(s => s.sourceAgents))].map(
agentName => (
<Box key={agentName} flexDirection="column" marginTop={1}>
<Box paddingLeft={2}>
<Text dimColor>@{agentName}</Text>
</Box>
{agentServers
.filter(s => s.sourceAgents.includes(agentName))
.map(agentServer => renderAgentServerItem(agentServer))}
{[...new Set(agentServers.flatMap(s => s.sourceAgents))].map(agentName => (
<Box key={agentName} flexDirection="column" marginTop={1}>
<Box paddingLeft={2}>
<Text dimColor>@{agentName}</Text>
</Box>
),
)}
{agentServers
.filter(s => s.sourceAgents.includes(agentName))
.map(agentServer => renderAgentServerItem(agentServer))}
</Box>
))}
</Box>
)}
@ -312,9 +279,7 @@ export function MCPListPanel({
<Box flexDirection="column" marginBottom={1}>
<Box paddingLeft={2}>
<Text bold>{dynamicHeading.label}</Text>
{dynamicHeading.path && (
<Text dimColor> ({dynamicHeading.path})</Text>
)}
{dynamicHeading.path && <Text dimColor> ({dynamicHeading.path})</Text>}
</Box>
{dynamicServers.map(server => renderServerItem(server))}
</Box>
@ -323,17 +288,15 @@ export function MCPListPanel({
{/* Footer info */}
<Box flexDirection="column">
{hasFailedClients && (
<Text dimColor>
{debugMode
? '※ Error logs shown inline with --debug'
: '※ Run claude --debug to see error logs'}
</Text>
<Box flexDirection="column">
<Text dimColor>{'※ Unavailable servers cannot be used until reconnected'}</Text>
<Text dimColor>
{debugMode ? '※ Error logs shown inline with --debug' : '※ Run claude --debug to see error logs'}
</Text>
</Box>
)}
<Text dimColor>
<Link url="https://costrict.ai/docs/en/mcp">
https://costrict.ai/docs/en/mcp
</Link>{' '}
for help
<Link url="https://costrict.ai/docs/en/mcp">https://costrict.ai/docs/en/mcp</Link> for help
</Text>
</Box>
</Box>
@ -345,15 +308,10 @@ export function MCPListPanel({
<Byline>
<KeyboardShortcutHint shortcut="↑↓" action="navigate" />
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
</Text>
</Box>
</Box>
)
);
}