Compare commits

...
6 changed files with 201 additions and 2 deletions
+15
View File
@@ -148,6 +148,7 @@ import { FileMentionMenu } from "./FileMentionMenu"
import { HelpPanelContent } from "./HelpPanelContent"
import { HighlightedInput } from "./HighlightedInput"
import { HistoryPanelContent } from "./HistoryPanelContent"
import { McpPanelContent } from "./McpPanelContent"
import { providerModels } from "./ModelPicker"
import { SettingsPanelContent } from "./SettingsPanelContent"
import { SkillsPanelContent } from "./SkillsPanelContent"
@@ -414,6 +415,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
| { type: "history" }
| { type: "help" }
| { type: "skills" }
| { type: "mcp" }
| null
>(null)
@@ -1166,6 +1168,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
setSlashMenuDismissed(true)
return
}
if (cmd.name === "mcp") {
setActivePanel({ type: "mcp" })
setTextInput("")
setCursorPos(0)
setSelectedSlashIndex(0)
setSlashMenuDismissed(true)
return
}
if (cmd.name === "clear") {
clearViewAndResetTask()
setSelectedSlashIndex(0)
@@ -1568,6 +1578,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
/>
)}
{/* MCP panel */}
{activePanel?.type === "mcp" && ctrl && (
<McpPanelContent controller={ctrl} onClose={() => setActivePanel(null)} />
)}
{/* Slash command menu - below input (takes priority over file menu) */}
{showSlashMenu && !activePanel && (
<Box paddingLeft={1} paddingRight={1}>
+4
View File
@@ -84,6 +84,10 @@ export const HelpPanelContent: React.FC<HelpPanelContentProps> = ({ onClose }) =
{" "}
<Text color="white">/history</Text> - Browse previous tasks
</Text>
<Text>
{" "}
<Text color="white">/mcp</Text> - View and toggle MCP servers
</Text>
<Text>
{" "}
<Text color="white">/clear</Text> - Start a fresh task
+156
View File
@@ -0,0 +1,156 @@
/**
* MCP panel content for inline display in ChatView
* Shows installed MCP servers with enable/disable toggle
*/
import { subscribeToMcpServerUpdates } from "@core/controller/mcp/subscribeToMcpServers"
import type { McpServer } from "@shared/mcp"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useEffect, useState } from "react"
import type { Controller } from "@/core/controller"
import { useStdinContext } from "../context/StdinContext"
import { isMouseEscapeSequence } from "../utils/input"
import { Panel } from "./Panel"
interface McpPanelContentProps {
controller: Controller
onClose: () => void
}
const MAX_VISIBLE = 8
export const McpPanelContent: React.FC<McpPanelContentProps> = ({ controller, onClose }) => {
const { isRawModeSupported } = useStdinContext()
const [servers, setServers] = useState<McpServer[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLoading, setIsLoading] = useState(true)
// Load MCP servers once on mount
useEffect(() => {
const loadServers = async () => {
try {
const mcpServers = (await controller.mcpHub?.getLatestMcpServersRPC()) || []
setServers(mcpServers)
} catch {
// Loading failed, show empty state
} finally {
setIsLoading(false)
}
}
loadServers()
}, [controller])
// Subscribe to real-time server updates (e.g. after a server finishes connecting)
useEffect(() => {
const unsubscribe = subscribeToMcpServerUpdates((updatedServers) => {
setServers(updatedServers)
})
return unsubscribe
}, [])
// Handle toggle
const handleToggle = useCallback(async () => {
const server = servers[selectedIndex]
if (!server) return
const newDisabled = !server.disabled
// Optimistic update
setServers((prev) => prev.map((s) => (s.name === server.name ? { ...s, disabled: newDisabled } : s)))
try {
const updated = await controller.mcpHub?.toggleServerDisabledRPC(server.name, newDisabled)
if (updated) {
setServers(updated)
}
} catch {
// Revert on failure
setServers((prev) => prev.map((s) => (s.name === server.name ? { ...s, disabled: !newDisabled } : s)))
}
}, [controller, servers, selectedIndex])
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) {
return
}
if (key.escape) {
onClose()
return
}
if (key.upArrow || input === "k") {
setSelectedIndex((i) => (i > 0 ? i - 1 : servers.length - 1))
return
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => (i < servers.length - 1 ? i + 1 : 0))
return
}
if ((key.return || key.tab || input === " ") && servers.length > 0) {
handleToggle()
return
}
},
{ isActive: isRawModeSupported },
)
// Scrolling window
const halfVisible = Math.floor(MAX_VISIBLE / 2)
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, servers.length - MAX_VISIBLE))
if (isLoading) {
return (
<Panel label="MCP Servers">
<Text color="gray">Loading MCP servers...</Text>
</Panel>
)
}
return (
<Panel label="MCP Servers">
<Box flexDirection="column" gap={1}>
{servers.length === 0 ? (
<Text color="gray">No MCP servers installed.</Text>
) : (
<Box flexDirection="column">
{servers.slice(startIndex, startIndex + MAX_VISIBLE).map((server, idx) => {
const actualIndex = startIndex + idx
const isSelected = actualIndex === selectedIndex
const enabled = !server.disabled
const statusColor = server.disabled
? "gray"
: server.status === "connected"
? "green"
: server.status === "connecting"
? "yellow"
: "red"
const statusLabel = server.disabled ? "disabled" : server.status
return (
<Box flexDirection="column" key={server.name}>
<Box>
<Text color={isSelected ? "cyan" : undefined}>
{isSelected ? " " : " "}
<Text color={enabled ? "green" : "red"}>{enabled ? "[✓]" : "[ ]"}</Text>
<Text> </Text>
<Text bold color="white">
{server.name}
</Text>
<Text color={statusColor}> ({statusLabel})</Text>
</Text>
</Box>
</Box>
)
})}
</Box>
)}
{/* Help text */}
{servers.length > 0 && (
<Box marginTop={1}>
<Text color="gray">/ Navigate Space/Enter/Tab Toggle</Text>
</Box>
)}
</Box>
</Panel>
)
}
@@ -1,6 +1,10 @@
import { McpServer } from "@shared/mcp"
import { EmptyRequest } from "@shared/proto/cline/common"
import { McpServers } from "@shared/proto/cline/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import {
convertMcpServersToProtoMcpServers,
convertProtoMcpServersToMcpServers,
} from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { Logger } from "@/shared/services/Logger"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
@@ -54,6 +58,18 @@ export async function subscribeToMcpServers(
}
}
/**
* Subscribe to MCP server updates outside of a gRPC request context (e.g. CLI).
* Returns an unsubscribe function to call on cleanup.
*/
export function subscribeToMcpServerUpdates(handler: (servers: McpServer[]) => void): () => void {
const responseStream: StreamingResponseHandler<McpServers> = async (mcpServers) => {
handler(convertProtoMcpServersToMcpServers(mcpServers.mcpServers))
}
activeMcpServersSubscriptions.add(responseStream)
return () => activeMcpServersSubscriptions.delete(responseStream)
}
/**
* Send an MCP servers update to all active subscribers
* @param mcpServers The MCP servers to send
+3 -1
View File
@@ -216,7 +216,9 @@ export function getSkillsDirectoriesForScan(cwd: string): SkillsScanDirectory[]
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
return getGlobalStorageDir("settings")
const settingsDir = path.join(getClineHomePath(), "data", "settings")
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
}
/**
+6
View File
@@ -97,4 +97,10 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
section: "default",
cliCompatible: true,
},
{
name: "mcp",
description: "Enable or disable installed MCP servers",
section: "default",
cliCompatible: true,
},
]