Merge pull request #113 from y574444354/opt/mcp-detector

opt/mcp detector
This commit is contained in:
linkai0924 2026-05-15 16:56:06 +08:00 committed by GitHub
commit 8f1d3cebd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

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