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:
parent
b0f10760fe
commit
c45544c066
|
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user