Merge pull request #113 from y574444354/opt/mcp-detector
opt/mcp detector
This commit is contained in:
commit
8f1d3cebd9
|
|
@ -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