diff --git a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
index 5c359c7d..482f35c0 100644
--- a/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values-ja/strings.xml
@@ -38,4 +38,22 @@
一致するツールがありません
パラメータ
必須
+ ツール
+ 履歴
+ MCP tools の呼び出し履歴はありません
+ 成功
+ 失敗
+ ツール名をコピー
+ 引数をコピー
+ 詳細をコピー
+ レスポンスをコピー
+ 引数なし
+ レスポンス
+ レスポンスなし
+ MCP tools を閲覧
+ プラグイン
+ セッション
+ すべて
+ フィルターを追加
+ フィルターを解除
diff --git a/jetwhale-host/app/src/main/composeResources/values/strings.xml b/jetwhale-host/app/src/main/composeResources/values/strings.xml
index f9328213..ccd2be5c 100644
--- a/jetwhale-host/app/src/main/composeResources/values/strings.xml
+++ b/jetwhale-host/app/src/main/composeResources/values/strings.xml
@@ -38,4 +38,22 @@
No matching tools
Parameters
required
+ Tools
+ History
+ No MCP tool calls yet
+ Succeeded
+ Failed
+ Copy tool name
+ Copy arguments
+ Copy details
+ Copy response
+ No arguments
+ Response
+ No response
+ Browse MCP tools
+ Plugin
+ Session
+ All
+ Add filter
+ Remove filter
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
index 7d880e1f..136b8d49 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/JetWhaleApp.kt
@@ -35,6 +35,7 @@ import com.kitakkun.jetwhale.host.navigation.addSingleTop
import com.kitakkun.jetwhale.host.navigation.bringPluginBackToMainWindow
import com.kitakkun.jetwhale.host.navigation.followPluginToSession
import com.kitakkun.jetwhale.host.navigation.isPluginPoppedOut
+import com.kitakkun.jetwhale.host.navigation.openMcpTools
import com.kitakkun.jetwhale.host.settings.SettingsScreenSegmentedMenu
import com.kitakkun.jetwhale.host.ui.AppEnvironment
import com.kitakkun.jetwhale.host.ui.JetWhaleTheme
@@ -139,6 +140,9 @@ fun JetWhaleApp() {
onClickPlugin = { pluginId, sessionId ->
backStack.addSingleTop(PluginNavKey(pluginId, sessionId))
},
+ onOpenMcpTools = { pluginId, sessionId ->
+ backStack.openMcpTools(pluginId = pluginId, sessionId = sessionId)
+ },
onClickPopout = { pluginId, pluginName, sessionId ->
backStack.addSingleTop(
PluginPopoutNavKey(
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
index 4af76e3e..ebdb3934 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/component/ToolingDrawer.kt
@@ -24,6 +24,8 @@ fun ToolingDrawer(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (String) -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onSelectSession: (DebugSession) -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
isPoppedOut: (pluginId: String) -> Boolean,
@@ -45,6 +47,8 @@ fun ToolingDrawer(
onClickShrinkDrawer = { expandMenu = false },
onClickSettings = onClickSettings,
onClickPluginSettings = onClickPluginSettings,
+ onOpenMcpTools = onOpenMcpTools,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onClickPlugin = { onClickPlugin(it.id) },
onSelectSession = onSelectSession,
onClickPopout = onClickPopout,
@@ -64,6 +68,7 @@ fun ToolingDrawer(
onClickExpandMenu = { expandMenu = true },
onClickSettings = onClickSettings,
onClickInfo = onClickInfo,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onSelectSession = onSelectSession,
)
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
index 6f1abd96..773e39f0 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/di/JetWhaleAppGraph.kt
@@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.host.di
import com.kitakkun.jetwhale.host.ApplicationLifecycleOwner
import com.kitakkun.jetwhale.host.BuildConfig
import com.kitakkun.jetwhale.host.architecture.ScreenContext
+import com.kitakkun.jetwhale.host.drawer.McpToolsScreenContext
import com.kitakkun.jetwhale.host.drawer.ToolingScaffoldScreenContext
import com.kitakkun.jetwhale.host.mcp.McpServerService
import com.kitakkun.jetwhale.host.model.AppearanceSettingsSubscriptionKey
@@ -38,6 +39,7 @@ interface JetWhaleAppGraph : ScreenContext {
val swrClient: SwrClientPlus
val toolingScaffoldScreenContext: ToolingScaffoldScreenContext
+ val mcpToolsScreenContext: McpToolsScreenContext
val licensesScreenContext: LicensesScreenContext
val settingsScreenContext: SettingsScreenContext
val pluginScreenContextFactory: PluginScreenContext.Factory
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
index 43ecaa9b..85fba52b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/DrawerPluginItemUiState.kt
@@ -1,9 +1,7 @@
package com.kitakkun.jetwhale.host.drawer
-import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginAvailability
import com.kitakkun.jetwhale.host.model.PluginIconResource
-import kotlinx.collections.immutable.ImmutableList
data class DrawerPluginItemUiState(
val name: String,
@@ -13,8 +11,6 @@ data class DrawerPluginItemUiState(
val pluginAvailability: PluginAvailability,
/** True while an AI agent is driving this plugin's UI in the selected session. */
val underAiControl: Boolean,
- /** The MCP tools this plugin exposes for the selected session; empty when it publishes none. */
- val mcpTools: ImmutableList,
-) {
- val exposesMcpTools: Boolean get() = mcpTools.isNotEmpty()
-}
+ /** True when this plugin publishes MCP tools of its own for the selected session. */
+ val exposesMcpTools: Boolean,
+)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
index 9d4dcd58..ca9d13b7 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ExpandedToolingDrawerView.kt
@@ -64,6 +64,8 @@ fun ExpandedToolingDrawerView(
onClickShrinkDrawer: () -> Unit,
onClickSettings: () -> Unit,
onClickPluginSettings: () -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onClickPlugin: (DrawerPluginItemUiState) -> Unit,
onSelectSession: (DebugSession) -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
@@ -82,11 +84,16 @@ fun ExpandedToolingDrawerView(
contentDescription = null,
)
}
- IconButton(onClick = onClickSettings) {
- Icon(
- imageVector = Icons.Default.Settings,
- contentDescription = null,
- )
+ Row {
+ // Opens the browser unscoped, so the tools an agent can reach are visible without
+ // first finding a plugin that happens to publish some.
+ McpToolsDrawerButton(onClick = onOpenAllMcpTools)
+ IconButton(onClick = onClickSettings) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = null,
+ )
+ }
}
}
Column(
@@ -169,7 +176,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = it.id == selectedPluginId,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = { onClickPlugin(it) },
popupMenuContent = { dismiss ->
DropdownMenuItem(
@@ -241,7 +249,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = false,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = {
// do nothing
},
@@ -287,7 +296,8 @@ fun ExpandedToolingDrawerView(
inactiveIconResource = it.inactiveIconResource,
selected = false,
underAiControl = it.underAiControl,
- mcpTools = it.mcpTools,
+ exposesMcpTools = it.exposesMcpTools,
+ onClickMcpBadge = { onOpenMcpTools(it.id) },
onClick = {
// do nothing
},
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
index 3784c31c..75c0bcdd 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/JetWhaleToolingScaffold.kt
@@ -19,6 +19,8 @@ fun ToolingScaffold(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (String) -> Unit,
+ onOpenMcpTools: (pluginId: String) -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onClickPopout: (DrawerPluginItemUiState) -> Unit,
isPoppedOut: (pluginId: String) -> Boolean,
onClickBringBack: (DrawerPluginItemUiState) -> Unit,
@@ -41,6 +43,8 @@ fun ToolingScaffold(
onClickPluginSettings = onClickPluginSettings,
onClickInfo = onClickInfo,
onClickPlugin = onClickPlugin,
+ onOpenMcpTools = onOpenMcpTools,
+ onOpenAllMcpTools = onOpenAllMcpTools,
onSelectSession = onSelectSession,
onClickPopout = onClickPopout,
isPoppedOut = isPoppedOut,
@@ -72,6 +76,8 @@ private fun ToolingScaffoldPreview() {
onClickPluginSettings = {},
onClickInfo = {},
onClickPlugin = {},
+ onOpenMcpTools = {},
+ onOpenAllMcpTools = {},
onSelectSession = {},
onClickPopout = {},
isPoppedOut = { false },
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt
new file mode 100644
index 00000000..6929a689
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsDrawerButton.kt
@@ -0,0 +1,39 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Text
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import com.kitakkun.jetwhale.host.Res
+import com.kitakkun.jetwhale.host.mcp_tools_open_all
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Drawer entry point to the MCP tools browser that is not tied to a plugin, so the browser is
+ * reachable even when no plugin badge is on screen.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun McpToolsDrawerButton(onClick: () -> Unit) {
+ val label = stringResource(Res.string.mcp_tools_open_all)
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = { PlainTooltip { Text(label) } },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Default.Build,
+ contentDescription = label,
+ )
+ }
+ }
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
new file mode 100644
index 00000000..b8ba948b
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreen.kt
@@ -0,0 +1,910 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.kitakkun.jetwhale.host.Res
+import com.kitakkun.jetwhale.host.mcp_history_copy_arguments
+import com.kitakkun.jetwhale.host.mcp_history_copy_details
+import com.kitakkun.jetwhale.host.mcp_history_copy_response
+import com.kitakkun.jetwhale.host.mcp_history_copy_tool_name
+import com.kitakkun.jetwhale.host.mcp_history_empty
+import com.kitakkun.jetwhale.host.mcp_history_failed
+import com.kitakkun.jetwhale.host.mcp_history_no_arguments
+import com.kitakkun.jetwhale.host.mcp_history_no_response
+import com.kitakkun.jetwhale.host.mcp_history_response
+import com.kitakkun.jetwhale.host.mcp_history_succeeded
+import com.kitakkun.jetwhale.host.mcp_tool_executing
+import com.kitakkun.jetwhale.host.mcp_tools_available
+import com.kitakkun.jetwhale.host.mcp_tools_filter_add
+import com.kitakkun.jetwhale.host.mcp_tools_filter_all
+import com.kitakkun.jetwhale.host.mcp_tools_filter_plugin
+import com.kitakkun.jetwhale.host.mcp_tools_filter_remove
+import com.kitakkun.jetwhale.host.mcp_tools_filter_session
+import com.kitakkun.jetwhale.host.mcp_tools_no_match
+import com.kitakkun.jetwhale.host.mcp_tools_parameters
+import com.kitakkun.jetwhale.host.mcp_tools_required
+import com.kitakkun.jetwhale.host.mcp_tools_search
+import com.kitakkun.jetwhale.host.mcp_tools_tab_history
+import com.kitakkun.jetwhale.host.mcp_tools_tab_tools
+import com.kitakkun.jetwhale.host.model.McpCallRecord
+import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import org.jetbrains.compose.resources.stringResource
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/** Everything the MCP tools browser draws, already narrowed to the selected plugin and session. */
+data class McpToolsScreenUiState(
+ val pluginOptions: ImmutableList,
+ val sessionOptions: ImmutableList,
+ val selectedPluginIds: ImmutableSet,
+ val selectedSessionIds: ImmutableSet,
+ val toolRows: ImmutableList,
+ val callHistory: ImmutableList,
+ val runningToolName: String?,
+)
+
+/** The panes the MCP browser can show: the tools plugins publish, or the calls already made. */
+internal enum class McpToolsTab {
+ Tools,
+ History,
+}
+
+private const val MCP_TOOLS_DIALOG_WINDOW_FRACTION = 0.8f
+
+@Composable
+fun McpToolsScreen(
+ uiState: McpToolsScreenUiState,
+ onSelectPluginFilters: (Set) -> Unit,
+ onSelectSessionFilters: (Set) -> Unit,
+) {
+ Surface(shape = MaterialTheme.shapes.large) {
+ Column(
+ modifier = Modifier
+ // Take most of the window so tool descriptions and history are readable, but stop
+ // growing past a comfortable reading width on a large display.
+ .fillMaxSize(MCP_TOOLS_DIALOG_WINDOW_FRACTION)
+ .sizeIn(
+ minWidth = 640.dp,
+ minHeight = 440.dp,
+ maxWidth = 1200.dp,
+ maxHeight = 860.dp,
+ )
+ .padding(20.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_plugin),
+ options = uiState.pluginOptions,
+ selectedIds = uiState.selectedPluginIds,
+ onSelectionChange = onSelectPluginFilters,
+ )
+ McpFilterChipGroup(
+ label = stringResource(Res.string.mcp_tools_filter_session),
+ options = uiState.sessionOptions,
+ selectedIds = uiState.selectedSessionIds,
+ onSelectionChange = onSelectSessionFilters,
+ )
+ }
+ Text(
+ text = stringResource(
+ if (uiState.runningToolName != null) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ // Held against the first chip row instead of the middle of a block whose height
+ // grows as chips wrap.
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ Spacer(Modifier.size(12.dp))
+
+ var selectedTab by remember { mutableStateOf(McpToolsTab.Tools) }
+ TabRow(selectedTabIndex = selectedTab.ordinal) {
+ Tab(
+ selected = selectedTab == McpToolsTab.Tools,
+ onClick = { selectedTab = McpToolsTab.Tools },
+ text = { Text(stringResource(Res.string.mcp_tools_tab_tools)) },
+ )
+ Tab(
+ selected = selectedTab == McpToolsTab.History,
+ onClick = { selectedTab = McpToolsTab.History },
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(stringResource(Res.string.mcp_tools_tab_history))
+ if (uiState.callHistory.isNotEmpty()) {
+ // How many calls the current scope holds, so the count is visible
+ // without opening the tab.
+ McpToolCallCountBadge(count = uiState.callHistory.size, running = false)
+ }
+ }
+ },
+ )
+ }
+ Spacer(Modifier.size(12.dp))
+
+ // Hoisted out of the pane so switching tabs and coming back keeps the search and the
+ // selected tool where the user left them.
+ var query by remember { mutableStateOf("") }
+ var selectedToolKey by remember { mutableStateOf(null) }
+
+ when (selectedTab) {
+ McpToolsTab.Tools -> McpToolsPane(
+ toolRows = uiState.toolRows,
+ query = query,
+ onQueryChange = { query = it },
+ selectedToolKey = selectedToolKey,
+ onSelectTool = { selectedToolKey = it },
+ modifier = Modifier.weight(1f),
+ )
+
+ McpToolsTab.History -> McpCallHistoryPane(
+ callHistory = uiState.callHistory,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+}
+
+/** Icon size shared by the filter chips and the entries of their picker. */
+private val McpFilterIconSize = 18.dp
+
+/**
+ * One filter group: a removable chip per picked value, plus a chip that opens the picker. An empty
+ * [selectedIds] narrows nothing, and the group reads as "All" until a value is picked.
+ */
+@Composable
+private fun McpFilterChipGroup(
+ label: String,
+ options: ImmutableList,
+ selectedIds: ImmutableSet,
+ onSelectionChange: (Set) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val allLabel = stringResource(Res.string.mcp_tools_filter_all)
+ val addLabel = stringResource(Res.string.mcp_tools_filter_add)
+ val removeLabel = stringResource(Res.string.mcp_tools_filter_remove)
+ // A picked value outlives the option that named it once a session goes away, so the raw id
+ // stands in rather than dropping a filter that is still narrowing the screen.
+ val selectedChips = remember(options, selectedIds) {
+ val labelsById = options.associate { it.id to it.label }
+ selectedIds
+ .map { id -> McpFilterOption(id = id, label = labelsById[id] ?: id) }
+ .sortedBy { it.label }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ // Fixed width and held against the first chip row so both groups' labels line up even
+ // when one of them wraps onto several rows.
+ modifier = Modifier
+ .width(76.dp)
+ .padding(top = 8.dp),
+ )
+ // Wraps so a long list of plugins or sessions grows downwards instead of widening a dialog
+ // that is already bounded.
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ selectedChips.forEach { option ->
+ InputChip(
+ selected = true,
+ onClick = { onSelectionChange(selectedIds - option.id) },
+ label = {
+ Text(
+ text = option.label,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.widthIn(max = 220.dp),
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = removeLabel,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
+ },
+ )
+ }
+ Box {
+ val filtering = selectedChips.isNotEmpty()
+ val addIcon: (@Composable () -> Unit)? = if (filtering) {
+ {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
+ }
+ } else {
+ null
+ }
+ AssistChip(
+ onClick = { expanded = true },
+ enabled = options.isNotEmpty(),
+ label = { Text(if (filtering) addLabel else allLabel) },
+ leadingIcon = addIcon,
+ )
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ // The menu survives every pick so several values can be added in one go, and a
+ // check marks what is already picked while it is open.
+ DropdownMenuItem(
+ text = { Text(allLabel) },
+ onClick = { onSelectionChange(emptySet()) },
+ trailingIcon = { if (selectedIds.isEmpty()) McpFilterSelectedCheck() },
+ )
+ options.forEach { option ->
+ val selected = option.id in selectedIds
+ DropdownMenuItem(
+ text = { Text(option.label) },
+ onClick = {
+ onSelectionChange(
+ if (selected) selectedIds - option.id else selectedIds + option.id,
+ )
+ },
+ trailingIcon = { if (selected) McpFilterSelectedCheck() },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Marks an entry of a filter picker as already narrowing the screen. */
+@Composable
+private fun McpFilterSelectedCheck() {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ modifier = Modifier.size(McpFilterIconSize),
+ )
+}
+
+/**
+ * Trailing badge on a tool row: the number of recorded calls, shown quietly. While an agent is
+ * running the tool it takes the accent fill and the same rotating ring the drawer item uses, so
+ * "being called right now" reads the same way everywhere.
+ */
+@Composable
+internal fun McpToolCallCountBadge(count: Int, running: Boolean) {
+ val shape = RoundedCornerShape(6.dp)
+ Box(
+ modifier = Modifier
+ .clip(shape)
+ .background(
+ if (running) AiOperatingAccentColor else MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape,
+ )
+ .then(
+ if (running) Modifier.aiOperatingBorder(color = AiOperatingAccentColor, width = 2.dp) else Modifier,
+ )
+ .padding(horizontal = 7.dp, vertical = 2.dp),
+ ) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = if (running) Color.Black else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
+
+/** Two-pane browser over the tools in scope: search + list on the left, detail right. */
+@Composable
+private fun McpToolsPane(
+ toolRows: ImmutableList,
+ query: String,
+ onQueryChange: (String) -> Unit,
+ selectedToolKey: String?,
+ onSelectTool: (String) -> Unit,
+ modifier: Modifier,
+) {
+ val filtered = remember(query, toolRows) {
+ if (query.isBlank()) {
+ toolRows
+ } else {
+ toolRows.filter {
+ it.tool.name.contains(query, ignoreCase = true) ||
+ it.tool.description.contains(query, ignoreCase = true) ||
+ it.pluginName.contains(query, ignoreCase = true)
+ }
+ }
+ }
+ val selected = filtered.firstOrNull { it.key == selectedToolKey } ?: filtered.firstOrNull()
+
+ Row(modifier = modifier) {
+ // Left pane: search + tool list.
+ Column(modifier = Modifier.width(320.dp)) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
+ placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.size(8.dp))
+ LazyColumn(modifier = Modifier.fillMaxHeight()) {
+ items(filtered, key = { it.key }) { row ->
+ val isSelected = row.key == selected?.key
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+ )
+ .clickable { onSelectTool(row.key) }
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ // Takes the free space so the count sits against the right edge.
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = row.tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ // The short name alone is ambiguous once plugins are mixed, so every row
+ // names the plugin that publishes the tool.
+ Text(
+ text = row.pluginName,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (row.callCount > 0 || row.running) {
+ McpToolCallCountBadge(count = row.callCount, running = row.running)
+ }
+ }
+ }
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ // Right pane: the selected tool's detail.
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (selected == null) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_no_match),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Text(
+ text = selected.tool.name.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = selected.pluginName,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = selected.tool.name,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = selected.tool.description,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (selected.tool.parameters.isNotEmpty()) {
+ Spacer(Modifier.size(4.dp))
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ selected.tool.parameters.forEach { param -> McpParameterRow(param) }
+ }
+ }
+ }
+ }
+}
+
+/** What agents already did in the selected scope, newest call first. */
+@Composable
+private fun McpCallHistoryPane(
+ callHistory: ImmutableList,
+ modifier: Modifier,
+) {
+ if (callHistory.isEmpty()) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_history_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ // Selection lives above the lazy list so it survives the row scrolling out of view.
+ var selectedCallId by remember { mutableStateOf(callHistory.first().id) }
+ val selected = callHistory.firstOrNull { it.id == selectedCallId } ?: callHistory.first()
+
+ Row(modifier = modifier) {
+ LazyColumn(
+ modifier = Modifier.width(320.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ items(callHistory, key = { it.id }) { record ->
+ McpCallHistoryRow(
+ record = record,
+ selected = record.id == selected.id,
+ onSelect = { selectedCallId = record.id },
+ )
+ }
+ }
+ VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
+ McpCallDetailPane(
+ record = selected,
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ )
+ }
+}
+
+/**
+ * Right pane: everything recorded about the selected call. Each section carries its own inline copy
+ * icon; the one action that takes the whole record stays a labelled button.
+ */
+@Composable
+private fun McpCallDetailPane(
+ record: McpCallRecord,
+ modifier: Modifier,
+) {
+ val statusLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+ val clipboardManager = LocalClipboardManager.current
+
+ Column(
+ modifier = modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.titleMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_tool_name),
+ onClick = { clipboardManager.setText(AnnotatedString(record.toolName)) },
+ )
+ }
+ Text(
+ text = record.toolName,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = null,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = statusLabel,
+ style = MaterialTheme.typography.labelMedium,
+ color = if (record.succeeded) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.error
+ },
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelMedium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_parameters),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.arguments.isNotEmpty()) {
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_arguments),
+ onClick = { clipboardManager.setText(AnnotatedString(renderedArguments)) },
+ )
+ }
+ }
+ if (record.arguments.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.mcp_history_no_arguments),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ record.arguments.forEach { argument ->
+ Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
+ Text(
+ text = argument.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ Text(
+ text = argument.value,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.size(4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.mcp_history_response),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ if (record.response.isNotEmpty()) {
+ McpCopyIconButton(
+ contentDescription = stringResource(Res.string.mcp_history_copy_response),
+ onClick = { clipboardManager.setText(AnnotatedString(record.response)) },
+ )
+ }
+ }
+ if (record.response.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.mcp_history_no_response),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ // Bounded and scrolled on its own so a long response stays readable instead of
+ // pushing the copy action out of the pane.
+ .heightIn(max = 240.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .verticalScroll(rememberScrollState())
+ .padding(8.dp),
+ ) {
+ Text(
+ text = record.response,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ Spacer(Modifier.size(4.dp))
+ TextButton(
+ onClick = {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ response = record.response,
+ ),
+ ),
+ )
+ },
+ ) {
+ Text(stringResource(Res.string.mcp_history_copy_details))
+ }
+ }
+}
+
+/** Copy affordance that sits beside a heading without competing with it for attention. */
+@Composable
+private fun McpCopyIconButton(
+ contentDescription: String,
+ onClick: () -> Unit,
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier.size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+}
+
+@Composable
+private fun McpCallHistoryRow(
+ record: McpCallRecord,
+ selected: Boolean,
+ onSelect: () -> Unit,
+) {
+ val statusLabel = stringResource(
+ if (record.succeeded) Res.string.mcp_history_succeeded else Res.string.mcp_history_failed,
+ )
+ val finishedAt = formatCallTime(record.finishedAtEpochMillis)
+ val renderedArguments = record.arguments.joinToString(separator = "\n") { "${it.name} = ${it.value}" }
+
+ val clipboardManager = LocalClipboardManager.current
+ val copyToolNameLabel = stringResource(Res.string.mcp_history_copy_tool_name)
+ val copyArgumentsLabel = stringResource(Res.string.mcp_history_copy_arguments)
+ val copyResponseLabel = stringResource(Res.string.mcp_history_copy_response)
+ val copyDetailsLabel = stringResource(Res.string.mcp_history_copy_details)
+
+ ContextMenuArea(
+ items = {
+ buildList {
+ add(
+ ContextMenuItem(copyToolNameLabel) {
+ clipboardManager.setText(AnnotatedString(record.toolName))
+ },
+ )
+ if (record.arguments.isNotEmpty()) {
+ add(
+ ContextMenuItem(copyArgumentsLabel) {
+ clipboardManager.setText(AnnotatedString(renderedArguments))
+ },
+ )
+ }
+ if (record.response.isNotEmpty()) {
+ add(
+ ContextMenuItem(copyResponseLabel) {
+ clipboardManager.setText(AnnotatedString(record.response))
+ },
+ )
+ }
+ add(
+ ContextMenuItem(copyDetailsLabel) {
+ clipboardManager.setText(
+ AnnotatedString(
+ buildCallDetails(
+ toolName = record.toolName,
+ statusLabel = statusLabel,
+ finishedAt = finishedAt,
+ renderedArguments = renderedArguments,
+ response = record.response,
+ ),
+ ),
+ )
+ },
+ )
+ }
+ },
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(6.dp))
+ .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
+ .clickable(role = Role.Button, onClick = onSelect)
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ imageVector = if (record.succeeded) Icons.Default.CheckCircle else Icons.Default.ErrorOutline,
+ contentDescription = statusLabel,
+ tint = if (record.succeeded) AiOperatingAccentColor else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = record.toolName.substringAfterLast('.'),
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (selected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = finishedAt,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+private fun buildCallDetails(
+ toolName: String,
+ statusLabel: String,
+ finishedAt: String,
+ renderedArguments: String,
+ response: String,
+): String = buildString {
+ appendLine(toolName)
+ appendLine(statusLabel)
+ append(finishedAt)
+ if (renderedArguments.isNotEmpty()) {
+ appendLine()
+ append(renderedArguments)
+ }
+ if (response.isNotEmpty()) {
+ // A blank line keeps the response apart from the arguments above it, which are otherwise
+ // laid out the same way.
+ appendLine()
+ appendLine()
+ append(response)
+ }
+}
+
+/** Wall-clock time of day, which is what the user can line up against their own actions. */
+private val CallHistoryTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
+
+private fun formatCallTime(epochMillis: Long): String = Instant.ofEpochMilli(epochMillis)
+ .atZone(ZoneId.systemDefault())
+ .format(CallHistoryTimeFormatter)
+
+@Composable
+private fun McpParameterRow(param: McpToolParameterSummary) {
+ Column(
+ modifier = Modifier.padding(top = 6.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = param.name,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ )
+ if (param.type.isNotEmpty()) {
+ Text(
+ text = param.type,
+ style = MaterialTheme.typography.labelSmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (param.required) {
+ Text(
+ text = stringResource(Res.string.mcp_tools_required),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ if (param.description.isNotEmpty()) {
+ Text(
+ text = param.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt
new file mode 100644
index 00000000..95828ba7
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenContext.kt
@@ -0,0 +1,20 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import com.kitakkun.jetwhale.host.architecture.ScreenContext
+import com.kitakkun.jetwhale.host.model.DebugSessionsSubscriptionKey
+import com.kitakkun.jetwhale.host.model.LoadedPluginsMetaDataSubscriptionKey
+import com.kitakkun.jetwhale.host.model.McpActivitySubscriptionKey
+import com.kitakkun.jetwhale.host.model.McpCapablePluginsSubscriptionKey
+import dev.zacsweers.metro.Inject
+
+/**
+ * The MCP tools browser subscribes to the whole picture itself rather than being handed one
+ * plugin's slice, so it can show every session and plugin and let the user narrow it down.
+ */
+@Inject
+class McpToolsScreenContext(
+ val mcpCapablePluginsSubscriptionKey: McpCapablePluginsSubscriptionKey,
+ val mcpActivitySubscriptionKey: McpActivitySubscriptionKey,
+ val debugSessionsSubscriptionKey: DebugSessionsSubscriptionKey,
+ val loadedPluginsMetaDataSubscriptionKey: LoadedPluginsMetaDataSubscriptionKey,
+) : ScreenContext
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
new file mode 100644
index 00000000..ff9a583e
--- /dev/null
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/McpToolsScreenRoot.kt
@@ -0,0 +1,163 @@
+package com.kitakkun.jetwhale.host.drawer
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.retain.retain
+import androidx.compose.runtime.setValue
+import com.kitakkun.jetwhale.host.architecture.SoilDataBoundary
+import com.kitakkun.jetwhale.host.model.DebugSession
+import com.kitakkun.jetwhale.host.model.McpActivity
+import com.kitakkun.jetwhale.host.model.McpCapablePlugins
+import com.kitakkun.jetwhale.host.model.McpToolSummary
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentSet
+import kotlinx.coroutines.delay
+import soil.query.compose.rememberSubscription
+import kotlin.time.Duration.Companion.milliseconds
+
+/** How long a finished MCP tool call keeps reading as "running" on the screen. */
+private val MCP_TOOLS_RUNNING_LINGER = 1500.milliseconds
+
+/** One tool row, carrying the plugin that publishes it because the list mixes plugins. */
+data class McpToolRowUiState(
+ val pluginId: String,
+ val pluginName: String,
+ val tool: McpToolSummary,
+ val callCount: Int,
+ val running: Boolean,
+) {
+ /** Unique across plugins publishing a tool of the same name. */
+ val key: String get() = "$pluginId/${tool.name}"
+}
+
+/** One value a filter group can be narrowed to. */
+data class McpFilterOption(
+ val id: String,
+ val label: String,
+)
+
+@Composable
+context(screenContext: McpToolsScreenContext)
+fun McpToolsScreenRoot(
+ initialPluginId: String?,
+ initialSessionId: String?,
+) {
+ SoilDataBoundary(
+ state1 = rememberSubscription(screenContext.loadedPluginsMetaDataSubscriptionKey),
+ state2 = rememberSubscription(screenContext.debugSessionsSubscriptionKey),
+ state3 = rememberSubscription(screenContext.mcpActivitySubscriptionKey),
+ state4 = rememberSubscription(screenContext.mcpCapablePluginsSubscriptionKey),
+ ) { loadedPlugins, debugSessions, mcpActivity, mcpCapablePlugins ->
+ // An empty set filters nothing, so the nav key's "no preference" maps onto it directly.
+ var selectedPluginIds by retain { mutableStateOf(setOfNotNull(initialPluginId).toPersistentSet()) }
+ var selectedSessionIds by retain { mutableStateOf(setOfNotNull(initialSessionId).toPersistentSet()) }
+
+ // A call can start and finish between two frames, so watching the running list drops fast
+ // calls entirely. Latch on the monotonic counter and hold briefly instead, matching how the
+ // drawer decides a plugin is under AI control.
+ var running by remember { mutableStateOf(false) }
+ LaunchedEffect(mcpActivity.startedCount) {
+ if (mcpActivity.startedCount > 0L) {
+ running = true
+ delay(MCP_TOOLS_RUNNING_LINGER)
+ running = false
+ }
+ }
+ val runningInvocation = mcpActivity.lastStartedInvocation?.takeIf { running }
+
+ val pluginNamesById = remember(loadedPlugins) { loadedPlugins.associate { it.id to it.name } }
+
+ McpToolsScreen(
+ uiState = rememberMcpToolsUiState(
+ mcpCapablePlugins = mcpCapablePlugins,
+ mcpActivity = mcpActivity,
+ debugSessions = debugSessions,
+ pluginNamesById = pluginNamesById,
+ selectedPluginIds = selectedPluginIds,
+ selectedSessionIds = selectedSessionIds,
+ runningPluginId = runningInvocation?.pluginId,
+ runningToolName = runningInvocation?.toolName,
+ ),
+ onSelectPluginFilters = { selectedPluginIds = it.toPersistentSet() },
+ onSelectSessionFilters = { selectedSessionIds = it.toPersistentSet() },
+ )
+ }
+}
+
+@Composable
+private fun rememberMcpToolsUiState(
+ mcpCapablePlugins: McpCapablePlugins,
+ mcpActivity: McpActivity,
+ debugSessions: ImmutableList,
+ pluginNamesById: Map,
+ selectedPluginIds: ImmutableSet,
+ selectedSessionIds: ImmutableSet,
+ runningPluginId: String?,
+ runningToolName: String?,
+): McpToolsScreenUiState {
+ val sessionOptions = remember(debugSessions) {
+ debugSessions
+ .map { McpFilterOption(id = it.id, label = "${it.deviceDisplayName} · ${it.appDisplayName}") }
+ .toImmutableList()
+ }
+
+ // Built from every session so the list of plugins does not shift under the user when they narrow
+ // the session filter, which would make their own plugin selection disappear.
+ val pluginOptions = remember(mcpCapablePlugins, mcpActivity.recentCalls, pluginNamesById) {
+ val publishingIds = mcpCapablePlugins.toolsBySessionAndPlugin.values.flatMap { it.keys }
+ val calledIds = mcpActivity.recentCalls.mapNotNull { it.pluginId }
+ (publishingIds + calledIds)
+ .distinct()
+ .map { McpFilterOption(id = it, label = pluginNamesById[it] ?: it) }
+ .sortedBy { it.label }
+ .toImmutableList()
+ }
+
+ // Calls that named no session came from a tool that targets none, so they stay visible under a
+ // specific session too; hiding them would make a session look quieter than it was.
+ val callHistory = remember(mcpActivity.recentCalls, selectedPluginIds, selectedSessionIds) {
+ mcpActivity.recentCalls
+ .filter { selectedPluginIds.isEmpty() || it.pluginId in selectedPluginIds }
+ .filter { selectedSessionIds.isEmpty() || it.sessionId == null || it.sessionId in selectedSessionIds }
+ .toImmutableList()
+ }
+
+ val toolRows = remember(mcpCapablePlugins, callHistory, pluginNamesById, selectedPluginIds, selectedSessionIds, runningPluginId, runningToolName) {
+ // The same plugin publishes the same tools in every session it is active in, so the scope can
+ // yield duplicates that carry no extra information.
+ val callCounts = callHistory.groupingBy { it.pluginId to it.toolName }.eachCount()
+ mcpCapablePlugins.toolsBySessionAndPlugin
+ .filterKeys { selectedSessionIds.isEmpty() || it in selectedSessionIds }
+ .values
+ .flatMap { toolsByPlugin -> toolsByPlugin.entries }
+ .filter { selectedPluginIds.isEmpty() || it.key in selectedPluginIds }
+ .flatMap { (pluginId, tools) -> tools.map { pluginId to it } }
+ .distinctBy { (pluginId, tool) -> "$pluginId/${tool.name}" }
+ .map { (pluginId, tool) ->
+ McpToolRowUiState(
+ pluginId = pluginId,
+ pluginName = pluginNamesById[pluginId] ?: pluginId,
+ tool = tool,
+ callCount = callCounts[pluginId to tool.name] ?: 0,
+ running = pluginId == runningPluginId && tool.name == runningToolName,
+ )
+ }
+ .sortedWith(compareBy({ it.pluginName }, { it.tool.name }))
+ .toImmutableList()
+ }
+
+ return McpToolsScreenUiState(
+ pluginOptions = pluginOptions,
+ sessionOptions = sessionOptions,
+ selectedPluginIds = selectedPluginIds,
+ selectedSessionIds = selectedSessionIds,
+ toolRows = toolRows,
+ callHistory = callHistory,
+ runningToolName = runningToolName,
+ )
+}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
index b1934c29..ba60d73b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/PluginDrawerItemView.kt
@@ -5,35 +5,21 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -44,26 +30,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
import com.kitakkun.jetwhale.host.Res
-import com.kitakkun.jetwhale.host.close
-import com.kitakkun.jetwhale.host.mcp_tool_executing
-import com.kitakkun.jetwhale.host.mcp_tools_available
-import com.kitakkun.jetwhale.host.mcp_tools_no_match
-import com.kitakkun.jetwhale.host.mcp_tools_parameters
-import com.kitakkun.jetwhale.host.mcp_tools_required
-import com.kitakkun.jetwhale.host.mcp_tools_search
-import com.kitakkun.jetwhale.host.model.McpToolParameterSummary
-import com.kitakkun.jetwhale.host.model.McpToolSummary
import com.kitakkun.jetwhale.host.model.PluginIconResource
import com.kitakkun.jetwhale.host.puzzle_filled
import com.kitakkun.jetwhale.host.puzzle_outlined
-import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.compose.resources.painterResource
-import org.jetbrains.compose.resources.stringResource
@Composable
fun PluginDrawerItemView(
@@ -71,10 +44,11 @@ fun PluginDrawerItemView(
name: String,
selected: Boolean,
underAiControl: Boolean,
- mcpTools: ImmutableList,
+ exposesMcpTools: Boolean,
activeIconResource: PluginIconResource?,
inactiveIconResource: PluginIconResource?,
onClick: () -> Unit,
+ onClickMcpBadge: () -> Unit,
popupMenuContent: (@Composable ColumnScope.(dismiss: () -> Unit) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
@@ -95,8 +69,11 @@ fun PluginDrawerItemView(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
- if (mcpTools.isNotEmpty()) {
- McpBadge(operating = underAiControl, pluginName = name, tools = mcpTools)
+ if (exposesMcpTools) {
+ McpBadge(
+ operating = underAiControl,
+ onClick = onClickMcpBadge,
+ )
}
}
},
@@ -154,15 +131,14 @@ fun PluginDrawerItemView(
/**
* A compact "MCP" badge shown after a plugin's name when it exposes MCP tools. It is filled while an
- * agent is running one of those tools and outlined otherwise, and opens a dialog listing the tools.
+ * agent is running one of those tools and outlined otherwise, and opens the MCP tools browser scoped
+ * to this plugin.
*/
@Composable
private fun McpBadge(
operating: Boolean,
- pluginName: String,
- tools: ImmutableList,
+ onClick: () -> Unit,
) {
- var showDialog by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(4.dp)
val contentColor = if (operating) Color.Black else MaterialTheme.colorScheme.onSurfaceVariant
val decoration = if (operating) {
@@ -174,7 +150,7 @@ private fun McpBadge(
modifier = Modifier
.clip(shape)
.then(decoration)
- .clickable { showDialog = true }
+ .clickable(onClick = onClick)
.padding(horizontal = 5.dp, vertical = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
@@ -184,7 +160,7 @@ private fun McpBadge(
style = MaterialTheme.typography.labelSmall,
color = contentColor,
)
- // Signals that clicking opens a separate dialog.
+ // Signals that clicking opens a separate window.
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
@@ -192,191 +168,4 @@ private fun McpBadge(
modifier = Modifier.size(11.dp),
)
}
- if (showDialog) {
- McpToolsDialog(
- operating = operating,
- pluginName = pluginName,
- tools = tools,
- onDismiss = { showDialog = false },
- )
- }
-}
-
-@Composable
-private fun McpToolsDialog(
- operating: Boolean,
- pluginName: String,
- tools: ImmutableList,
- onDismiss: () -> Unit,
-) {
- Dialog(onDismissRequest = onDismiss) {
- Surface(shape = MaterialTheme.shapes.large) {
- Column(
- modifier = Modifier
- .width(760.dp)
- .height(500.dp)
- .padding(20.dp),
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = pluginName,
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.weight(1f),
- )
- Text(
- text = stringResource(
- if (operating) Res.string.mcp_tool_executing else Res.string.mcp_tools_available,
- ),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.size(12.dp))
-
- var query by remember { mutableStateOf("") }
- var selectedName by remember { mutableStateOf(tools.firstOrNull()?.name) }
- val filtered = remember(query, tools) {
- if (query.isBlank()) {
- tools
- } else {
- tools.filter {
- it.name.contains(query, ignoreCase = true) ||
- it.description.contains(query, ignoreCase = true)
- }
- }
- }
- val selected = filtered.firstOrNull { it.name == selectedName } ?: filtered.firstOrNull()
-
- Row(modifier = Modifier.weight(1f)) {
- // Left pane: search + tool list.
- Column(modifier = Modifier.width(260.dp)) {
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- singleLine = true,
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
- placeholder = { Text(stringResource(Res.string.mcp_tools_search)) },
- modifier = Modifier.fillMaxWidth(),
- )
- Spacer(Modifier.size(8.dp))
- LazyColumn(modifier = Modifier.fillMaxHeight()) {
- items(filtered, key = { it.name }) { tool ->
- val isSelected = tool.name == selected?.name
- Text(
- text = tool.name.substringAfterLast('.'),
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (isSelected) {
- MaterialTheme.colorScheme.onSecondaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(6.dp))
- .background(
- if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
- )
- .clickable { selectedName = tool.name }
- .padding(horizontal = 10.dp, vertical = 8.dp),
- )
- }
- }
- }
- VerticalDivider(modifier = Modifier.padding(horizontal = 12.dp))
- // Right pane: the selected tool's detail.
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (selected == null) {
- Text(
- text = stringResource(Res.string.mcp_tools_no_match),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else {
- Text(
- text = selected.name.substringAfterLast('.'),
- style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
- )
- Text(
- text = selected.name,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- text = selected.description,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (selected.parameters.isNotEmpty()) {
- Spacer(Modifier.size(4.dp))
- Text(
- text = stringResource(Res.string.mcp_tools_parameters),
- style = MaterialTheme.typography.labelLarge,
- )
- selected.parameters.forEach { param -> McpParameterRow(param) }
- }
- }
- }
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.End,
- ) {
- TextButton(onClick = onDismiss) {
- Text(stringResource(Res.string.close))
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun McpParameterRow(param: McpToolParameterSummary) {
- Column(
- modifier = Modifier.padding(top = 6.dp),
- verticalArrangement = Arrangement.spacedBy(2.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- Text(
- text = param.name,
- style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
- )
- if (param.type.isNotEmpty()) {
- Text(
- text = param.type,
- style = MaterialTheme.typography.labelSmall,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- if (param.required) {
- Text(
- text = stringResource(Res.string.mcp_tools_required),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
- if (param.description.isNotEmpty()) {
- Text(
- text = param.description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
index 3938bd76..04af84c4 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ShrunkToolingDrawerView.kt
@@ -61,6 +61,7 @@ fun ShrunkToolingDrawerView(
onClickSettings: () -> Unit,
onClickPlugin: (String) -> Unit,
onClickInfo: () -> Unit,
+ onOpenAllMcpTools: () -> Unit,
onSelectSession: (DebugSession) -> Unit,
) {
Column(
@@ -126,6 +127,9 @@ fun ShrunkToolingDrawerView(
contentDescription = null,
)
}
+ // Opens the browser unscoped, so the tools an agent can reach are visible without first
+ // finding a plugin that happens to publish some.
+ McpToolsDrawerButton(onClick = onOpenAllMcpTools)
CompactAiActivityIndicatorView(uiState = aiActivity)
HorizontalDivider()
LazyColumn(
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
index 3c76187e..5dff2056 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldPresenter.kt
@@ -101,7 +101,7 @@ fun toolingScaffoldPresenter(
else -> PluginAvailability.Disabled
},
underAiControl = aiControlledPluginId == metaData.id,
- mcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).toImmutableList(),
+ exposesMcpTools = mcpCapablePlugins.toolsFor(selectedSession?.id, metaData.id).isNotEmpty(),
)
}.toImmutableList()
}
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
index 39a22fdc..55aa63b0 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/drawer/ToolingScaffoldRoot.kt
@@ -15,6 +15,7 @@ fun ToolingScaffoldRoot(
onClickPluginSettings: () -> Unit,
onClickInfo: () -> Unit,
onClickPlugin: (pluginId: String, sessionId: String) -> Unit,
+ onOpenMcpTools: (pluginId: String?, sessionId: String?) -> Unit,
onClickPopout: (pluginId: String, pluginName: String, sessionId: String) -> Unit,
isPoppedOut: (pluginId: String, sessionId: String) -> Boolean,
onClickBringBack: (pluginId: String, sessionId: String) -> Unit,
@@ -68,6 +69,10 @@ fun ToolingScaffoldRoot(
screenChannel.send(ToolingScaffoldScreenAction.UpdateSelectedPlugin(it))
onClickPlugin(it, selectedSession.id)
},
+ // The browser tolerates a missing session, so the badge stays usable while no session
+ // is selected: it simply opens with the session filter on "All".
+ onOpenMcpTools = { onOpenMcpTools(it, uiState.selectedSession?.id) },
+ onOpenAllMcpTools = { onOpenMcpTools(null, null) },
onClickPopout = {
val selectedSession = uiState.selectedSession ?: return@ToolingScaffold
onClickPopout(it.id, it.name, selectedSession.id)
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
index 4461e531..dbe665b5 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavDisplay.kt
@@ -62,6 +62,7 @@ fun JetWhaleNavDisplay(
)
licensesEntry(onClickBack = backStack::removeLastOrNull)
logViewerEntry()
+ mcpToolsEntry()
pluginEntries(
isOpenedOnPopout = backStack::isPluginPoppedOut,
onBringbackToMainWindow = backStack::bringPluginBackToMainWindow,
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
index 272d34c2..38ab0995 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/JetWhaleNavKeys.kt
@@ -36,3 +36,14 @@ data object DisabledPluginNavKey : NavKey
@Serializable
data object LogViewerNavKey : NavKey
+
+/**
+ * The MCP tools browser. [pluginId] and [sessionId] seed the screen's filters — null means
+ * "all", so opening it from a plugin's badge lands on that plugin while the screen itself can
+ * widen the view afterwards.
+ */
+@Serializable
+data class McpToolsNavKey(
+ val pluginId: String?,
+ val sessionId: String?,
+) : NavKey
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
index 6aa7ce2c..79f185e3 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavBackStackExtensions.kt
@@ -13,6 +13,20 @@ fun NavBackStack.addSingleTop(index: Int, navKey: T) {
add(index, navKey)
}
+/**
+ * Shows the MCP tools browser, seeded with the scope it was opened from.
+ *
+ * At most one browser window exists: opening it from a different scope re-seeds the filters rather
+ * than stacking a second window, and re-opening it with the same scope leaves the window as it is so
+ * the user does not lose its position or their own filter changes.
+ */
+fun NavBackStack.openMcpTools(pluginId: String?, sessionId: String?) {
+ val navKey = McpToolsNavKey(pluginId = pluginId, sessionId = sessionId)
+ if (any { it == navKey }) return
+ removeAll { it is McpToolsNavKey }
+ add(navKey)
+}
+
/**
* Whether the given plugin is currently shown in a separate popout window for [sessionId].
*/
diff --git a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
index 9dffec31..9ae5327b 100644
--- a/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
+++ b/jetwhale-host/app/src/main/kotlin/com/kitakkun/jetwhale/host/navigation/NavEntries.kt
@@ -15,6 +15,7 @@ import androidx.navigation3.runtime.NavKey
import com.kitakkun.jetwhale.host.LocalComposeWindow
import com.kitakkun.jetwhale.host.Res
import com.kitakkun.jetwhale.host.di.JetWhaleAppGraph
+import com.kitakkun.jetwhale.host.drawer.McpToolsScreenRoot
import com.kitakkun.jetwhale.host.log_viewer_window_title
import com.kitakkun.jetwhale.host.plugin.PluginScreenRoot
import com.kitakkun.jetwhale.host.screen.EmptyPluginScreen
@@ -184,3 +185,26 @@ fun EntryProviderScope.logViewerEntry() {
}
}
}
+
+context(appGraph: JetWhaleAppGraph)
+fun EntryProviderScope.mcpToolsEntry() {
+ entry(
+ // The browser sizes itself; the platform default width would squeeze it to a narrow column.
+ metadata = StableDialogSceneStrategy.dialog(
+ dialogProperties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ ),
+ ),
+ ) { navKey ->
+ context(
+ retain {
+ appGraph.mcpToolsScreenContext
+ },
+ ) {
+ McpToolsScreenRoot(
+ initialPluginId = navKey.pluginId,
+ initialSessionId = navKey.sessionId,
+ )
+ }
+ }
+}
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
index b9b1e08b..d7ec4c4a 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepository.kt
@@ -2,6 +2,8 @@ package com.kitakkun.jetwhale.host.data.server
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallArgument
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@@ -33,13 +35,21 @@ class DefaultMcpActivityRepository : McpActivityRepository {
}
}
- override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long {
+ override fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long {
val invocationId = nextInvocationId.incrementAndGet()
val invocation = McpToolInvocation(
id = invocationId,
toolName = toolName,
pluginId = pluginId,
sessionId = sessionId,
+ arguments = arguments
+ .map { (name, value) -> McpCallArgument.truncating(name, value) }
+ .toImmutableList(),
)
activityFlow.update {
it.copy(
@@ -51,12 +61,34 @@ class DefaultMcpActivityRepository : McpActivityRepository {
return invocationId
}
- override fun toolInvocationFinished(invocationId: Long) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) {
+ // Sampled once outside the update block, which may re-run under contention.
+ val finishedAtEpochMillis = System.currentTimeMillis()
+ val truncatedResponse = McpCallRecord.truncateResponse(response)
activityFlow.update { activity ->
+ val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
runningInvocations = activity.runningInvocations
.filterNot { it.id == invocationId }
.toImmutableList(),
+ recentCalls = if (finished == null) {
+ // The invocation is unknown here, so there is nothing to describe in history.
+ activity.recentCalls
+ } else {
+ val record = McpCallRecord(
+ id = finished.id,
+ toolName = finished.toolName,
+ pluginId = finished.pluginId,
+ sessionId = finished.sessionId,
+ succeeded = !failed,
+ finishedAtEpochMillis = finishedAtEpochMillis,
+ arguments = finished.arguments,
+ response = truncatedResponse,
+ )
+ (listOf(record) + activity.recentCalls)
+ .take(McpActivity.MAX_RECENT_CALLS)
+ .toImmutableList()
+ },
)
}
}
diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
new file mode 100644
index 00000000..2ea14ebb
--- /dev/null
+++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/server/DefaultMcpActivityRepositoryTest.kt
@@ -0,0 +1,197 @@
+package com.kitakkun.jetwhale.host.data.server
+
+import com.kitakkun.jetwhale.host.model.McpActivity
+import com.kitakkun.jetwhale.host.model.McpCallArgument
+import com.kitakkun.jetwhale.host.model.McpCallRecord
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class DefaultMcpActivityRepositoryTest {
+
+ private val repository = DefaultMcpActivityRepository()
+
+ @Test
+ fun `no calls are recorded before anything runs`() {
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `a completed call is recorded with its attribution`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertEquals("plugin.click", record.toolName)
+ assertEquals("com.example.plugin", record.pluginId)
+ assertEquals("session-1", record.sessionId)
+ assertTrue(record.succeeded)
+ }
+
+ @Test
+ fun `a call is only recorded once it finishes`() {
+ repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `a failed call is recorded as unsuccessful`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = true, response = "")
+
+ assertFalse(repository.activityFlow.value.recentCalls.single().succeeded)
+ }
+
+ @Test
+ fun `history is ordered newest first`() {
+ listOf("first", "second", "third").forEach { toolName ->
+ val id = repository.toolInvocationStarted(toolName, "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+ }
+
+ assertEquals(
+ listOf("third", "second", "first"),
+ repository.activityFlow.value.recentCalls.map { it.toolName },
+ )
+ }
+
+ @Test
+ fun `history drops the oldest calls once the cap is reached`() {
+ val overflow = McpActivity.MAX_RECENT_CALLS + 5
+ repeat(overflow) { index ->
+ val id = repository.toolInvocationStarted("tool-$index", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+ }
+
+ val recentCalls = repository.activityFlow.value.recentCalls
+ assertEquals(McpActivity.MAX_RECENT_CALLS, recentCalls.size)
+ assertEquals("tool-${overflow - 1}", recentCalls.first().toolName)
+ assertEquals("tool-${overflow - McpActivity.MAX_RECENT_CALLS}", recentCalls.last().toolName)
+ }
+
+ @Test
+ fun `finishing an unknown invocation records nothing`() {
+ repository.toolInvocationFinished(invocationId = 404L, failed = false, response = "")
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+
+ @Test
+ fun `a completed call is recorded with the arguments it was made with`() {
+ val id = repository.toolInvocationStarted(
+ "plugin.click",
+ "com.example.plugin",
+ "session-1",
+ mapOf("sessionId" to "session-1", "x" to "100"),
+ )
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertEquals(
+ listOf(
+ McpCallArgument("sessionId", "session-1"),
+ McpCallArgument("x", "100"),
+ ),
+ record.arguments,
+ )
+ }
+
+ @Test
+ fun `a long argument value is truncated with an ellipsis`() {
+ val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH + 20)
+ val id = repository.toolInvocationStarted(
+ "plugin.type",
+ "com.example.plugin",
+ "session-1",
+ mapOf("text" to value),
+ )
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ val recorded = repository.activityFlow.value.recentCalls.single().arguments.single()
+ assertEquals(
+ "a".repeat(McpCallArgument.MAX_VALUE_LENGTH) + McpCallArgument.TRUNCATION_MARKER,
+ recorded.value,
+ )
+ }
+
+ @Test
+ fun `an argument value at the cap is kept whole`() {
+ val value = "a".repeat(McpCallArgument.MAX_VALUE_LENGTH)
+ val id = repository.toolInvocationStarted(
+ "plugin.type",
+ "com.example.plugin",
+ "session-1",
+ mapOf("text" to value),
+ )
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertEquals(value, repository.activityFlow.value.recentCalls.single().arguments.single().value)
+ }
+
+ @Test
+ fun `a call without arguments records an empty argument list`() {
+ val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertTrue(repository.activityFlow.value.recentCalls.single().arguments.isEmpty())
+ }
+
+ @Test
+ fun `a completed call is recorded with the response it produced`() {
+ val id = repository.toolInvocationStarted("plugin.screenshot", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertEquals("", repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a long response is truncated with an ellipsis`() {
+ val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH + 20)
+ val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = response)
+
+ assertEquals(
+ "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER,
+ repository.activityFlow.value.recentCalls.single().response,
+ )
+ }
+
+ @Test
+ fun `a response at the cap is kept whole`() {
+ val response = "a".repeat(McpCallRecord.MAX_RESPONSE_LENGTH)
+ val id = repository.toolInvocationStarted("plugin.tree", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = response)
+
+ assertEquals(response, repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a call without a response records an empty response`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ assertEquals("", repository.activityFlow.value.recentCalls.single().response)
+ }
+
+ @Test
+ fun `a failed call is recorded with the failure message as its response`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = true, response = "boom")
+
+ val record = repository.activityFlow.value.recentCalls.single()
+ assertFalse(record.succeeded)
+ assertEquals("boom", record.response)
+ }
+
+ @Test
+ fun `clear drops the recorded history`() {
+ val id = repository.toolInvocationStarted("plugin.click", "com.example.plugin", "session-1", emptyMap())
+ repository.toolInvocationFinished(id, failed = false, response = "")
+
+ repository.clear()
+
+ assertTrue(repository.activityFlow.value.recentCalls.isEmpty())
+ }
+}
diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
index 9ceb2169..77de1d5a 100644
--- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
+++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt
@@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
/**
@@ -78,12 +79,52 @@ class McpToolRegistrar(
toolName = name,
pluginId = resolvePluginId(request),
sessionId = request.arguments?.get("sessionId")?.jsonContent,
+ // Every argument key is reported, including the ones the UI already shows as
+ // attribution, so history describes the call exactly as the agent made it.
+ arguments = request.arguments.orEmpty().mapValues { (_, value) ->
+ // Primitives render without the surrounding JSON quoting; objects and arrays
+ // fall back to their JSON form.
+ value.jsonContent ?: value.toString()
+ },
)
+ // A tool fails in two ways: the handler throws, or it returns a result flagged with
+ // `isError`, which is how the protocol wants a tool-level failure reported. Both have to
+ // reach the repository before the call leaves here.
+ var failed = true
+ var response = ""
try {
- handler(request)
+ handler(request).also {
+ failed = it.isError == true
+ response = it.renderForHistory()
+ }
+ } catch (throwable: Throwable) {
+ // A failure is only explainable in history if it says what went wrong.
+ response = throwable.message.orEmpty()
+ throw throwable
} finally {
- activityRepository.toolInvocationFinished(invocationId)
+ activityRepository.toolInvocationFinished(invocationId, failed, response)
}
}
}
}
+
+/**
+ * Renders a tool result to the text kept in call history.
+ *
+ * Only text blocks carry something a reader can use; every other block type is a binary payload (a
+ * screenshot, audio, an embedded resource) that is named rather than inlined, so history does not
+ * fill up with base64.
+ *
+ * A structured payload follows the blocks on its own line. It is the whole answer for a tool that
+ * replies only in `structuredContent`, and it reads as the machine-readable detail behind the prose
+ * for a tool that sends both.
+ */
+private fun CallToolResult.renderForHistory(): String {
+ val renderedBlocks = content.map { block ->
+ when (block) {
+ is TextContent -> block.text
+ else -> "<${block.type.value}>"
+ }
+ }
+ return (renderedBlocks + listOfNotNull(structuredContent?.toString())).joinToString(separator = "\n")
+}
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
index 39a4f15f..c8fe4ac3 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt
@@ -19,6 +19,7 @@ import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.mcpSse
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
+import io.modelcontextprotocol.kotlin.sdk.types.ImageContent
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import kotlinx.coroutines.TimeoutCancellationException
@@ -27,6 +28,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -411,6 +414,146 @@ class DefaultMcpServerServiceTest {
assertTrue(mcpActivityRepository.activityFlow.value.runningInvocations.isEmpty())
}
+ @Test
+ fun `a completed tool call is added to the recent-call history`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(FakeMcpTool("fake.recorded")),
+ )
+ val recordedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, recordedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$recordedPort/sse")
+ try {
+ client.callTool(
+ "fake.recorded",
+ mapOf("pluginId" to "com.example.plugin", "sessionId" to "session-1"),
+ )
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.recorded", record.toolName)
+ assertEquals("com.example.plugin", record.pluginId)
+ assertEquals("session-1", record.sessionId)
+ assertTrue(record.succeeded)
+ assertEquals("ok", record.response)
+ }
+
+ @Test
+ fun `a non-text response block is recorded as a placeholder instead of its payload`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(MediaMcpTool("fake.captured")),
+ )
+ val capturedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, capturedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$capturedPort/sse")
+ try {
+ client.callTool("fake.captured", emptyMap())
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("captured\n", record.response)
+ }
+
+ @Test
+ fun `a tool call that reports an error without throwing is recorded as a failure`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(ErrorResultMcpTool("fake.rejected")),
+ )
+ val rejectedPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, rejectedPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$rejectedPort/sse")
+ try {
+ val result = client.callTool("fake.rejected", emptyMap())
+ // The handler returns normally, so nothing but `isError` marks this as a failure.
+ assertEquals(true, result.isError)
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.rejected", record.toolName)
+ assertFalse(record.succeeded)
+ assertEquals("""{"error":"no such element"}""", record.response)
+ }
+
+ @Test
+ fun `a structured response is recorded alongside the text content`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(StructuredMcpTool("fake.structured")),
+ )
+ val structuredPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, structuredPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$structuredPort/sse")
+ try {
+ client.callTool("fake.structured", emptyMap())
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertTrue(record.succeeded)
+ assertEquals("measured", record.response.lineSequence().first())
+ assertTrue("\"width\":120" in record.response, "Structured payload missing from ${record.response}")
+ }
+
+ @Test
+ fun `a throwing tool call is recorded in history as a failure`() = runBlocking {
+ val serviceWithTool = DefaultMcpServerService(
+ pluginInstanceService = pluginInstanceService,
+ mcpActivityRepository = mcpActivityRepository,
+ builtInTools = setOf(FailingMcpTool("fake.failing")),
+ )
+ val failingPort = java.net.ServerSocket(0).use { it.localPort }
+ serviceWithTool.start(host, failingPort)
+ // Stopping the server clears recorded activity, so the history has to be read while it runs.
+ val record = try {
+ val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$failingPort/sse")
+ try {
+ runCatching { client.callTool("fake.failing", emptyMap()) }
+ } finally {
+ client.close()
+ }
+ mcpActivityRepository.activityFlow.value.recentCalls.single()
+ } finally {
+ serviceWithTool.stop()
+ }
+
+ assertEquals("fake.failing", record.toolName)
+ assertFalse(record.succeeded)
+ assertEquals("boom", record.response)
+ }
+
@OptIn(ExperimentalJetWhaleApi::class)
@Test
fun `a plugin tool call is attributed to the plugin that owns it`() = runBlocking {
@@ -485,6 +628,44 @@ private class FakeMcpTool(
}
}
+/** Returns a text block alongside a binary one, which history must name rather than inline. */
+private class MediaMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Returns text and an image", inputSchema = ToolSchema()) { _ ->
+ CallToolResult(
+ content = listOf(
+ TextContent("captured"),
+ ImageContent(data = "AAAA", mimeType = "image/png"),
+ ),
+ )
+ }
+ }
+}
+
+/** Reports a tool-level failure the way the protocol prefers: a normal return flagged `isError`. */
+private class ErrorResultMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Always reports an error result", inputSchema = ToolSchema()) { _ ->
+ errorResult("no such element")
+ }
+ }
+}
+
+/** Answers with both prose and a machine-readable payload, as a tool with an output schema does. */
+private class StructuredMcpTool(private val name: String) : JetWhaleMcpTool {
+ override fun register(registrar: McpToolRegistrar) {
+ registrar.addTool(name = name, description = "Returns structured content", inputSchema = ToolSchema()) { _ ->
+ CallToolResult(
+ content = listOf(TextContent("measured")),
+ structuredContent = buildJsonObject {
+ put("width", 120)
+ put("height", 40)
+ },
+ )
+ }
+ }
+}
+
private class FailingMcpTool(private val name: String) : JetWhaleMcpTool {
override fun register(registrar: McpToolRegistrar) {
registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ ->
diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
index d152da36..d1a92ddb 100644
--- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
+++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/FakeMcpActivityRepository.kt
@@ -2,6 +2,8 @@ package com.kitakkun.jetwhale.host.mcp
import com.kitakkun.jetwhale.host.model.McpActivity
import com.kitakkun.jetwhale.host.model.McpActivityRepository
+import com.kitakkun.jetwhale.host.model.McpCallArgument
+import com.kitakkun.jetwhale.host.model.McpCallRecord
import com.kitakkun.jetwhale.host.model.McpToolInvocation
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,12 +35,20 @@ class FakeMcpActivityRepository : McpActivityRepository {
}
}
- override fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long {
+ override fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long {
val invocation = McpToolInvocation(
id = nextInvocationId.incrementAndGet(),
toolName = toolName,
pluginId = pluginId,
sessionId = sessionId,
+ arguments = arguments
+ .map { (name, value) -> McpCallArgument.truncating(name, value) }
+ .toImmutableList(),
)
_recordedInvocations += invocation
activityFlow.update {
@@ -51,12 +61,32 @@ class FakeMcpActivityRepository : McpActivityRepository {
return invocation.id
}
- override fun toolInvocationFinished(invocationId: Long) {
+ override fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String) {
+ val finishedAtEpochMillis = System.currentTimeMillis()
+ val truncatedResponse = McpCallRecord.truncateResponse(response)
activityFlow.update { activity ->
+ val finished = activity.runningInvocations.firstOrNull { it.id == invocationId }
activity.copy(
runningInvocations = activity.runningInvocations
.filterNot { it.id == invocationId }
.toImmutableList(),
+ recentCalls = if (finished == null) {
+ activity.recentCalls
+ } else {
+ val record = McpCallRecord(
+ id = finished.id,
+ toolName = finished.toolName,
+ pluginId = finished.pluginId,
+ sessionId = finished.sessionId,
+ succeeded = !failed,
+ finishedAtEpochMillis = finishedAtEpochMillis,
+ arguments = finished.arguments,
+ response = truncatedResponse,
+ )
+ (listOf(record) + activity.recentCalls)
+ .take(McpActivity.MAX_RECENT_CALLS)
+ .toImmutableList()
+ },
)
}
}
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
index dc9f575c..9a5f7b2c 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivity.kt
@@ -3,6 +3,36 @@ package com.kitakkun.jetwhale.host.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+/**
+ * One argument a tool call was made with, already rendered for display.
+ *
+ * [value] is the argument rendered to a string and shortened to [MAX_VALUE_LENGTH] characters, so a
+ * call carrying a large body (a screenshot, an accessibility tree, ...) does not keep that body
+ * alive for as long as the call stays in history.
+ */
+data class McpCallArgument(
+ val name: String,
+ val value: String,
+) {
+ companion object {
+ /** How many characters of an argument value are kept. */
+ const val MAX_VALUE_LENGTH = 80
+
+ /** Appended to a [value] that was cut, so the UI does not have to mark it itself. */
+ const val TRUNCATION_MARKER = "…"
+
+ /** Builds an argument whose [value] is shortened to [MAX_VALUE_LENGTH] characters. */
+ fun truncating(name: String, value: String): McpCallArgument = McpCallArgument(
+ name = name,
+ value = if (value.length <= MAX_VALUE_LENGTH) {
+ value
+ } else {
+ value.take(MAX_VALUE_LENGTH) + TRUNCATION_MARKER
+ },
+ )
+ }
+}
+
/**
* A single MCP tool call that is currently being executed on behalf of an AI agent.
*
@@ -15,8 +45,50 @@ data class McpToolInvocation(
val toolName: String,
val pluginId: String?,
val sessionId: String?,
+ /** Every argument the call was made with, including `pluginId` and `sessionId`. */
+ val arguments: ImmutableList,
)
+/**
+ * A finished MCP tool call, kept so the UI can show what an agent already did.
+ *
+ * [finishedAtEpochMillis] is wall-clock time in epoch milliseconds, taken when the call completed.
+ */
+data class McpCallRecord(
+ val id: Long,
+ val toolName: String,
+ val pluginId: String?,
+ val sessionId: String?,
+ val succeeded: Boolean,
+ val finishedAtEpochMillis: Long,
+ /** Every argument the call was made with, including `pluginId` and `sessionId`. */
+ val arguments: ImmutableList,
+ /**
+ * What the call produced, rendered to a string and shortened to [MAX_RESPONSE_LENGTH]. Carries
+ * the failure message when the call failed, and is empty when there is nothing to show.
+ */
+ val response: String,
+) {
+ companion object {
+ /**
+ * How many characters of a [response] are kept. Far more generous than
+ * [McpCallArgument.MAX_VALUE_LENGTH]: the response is the payload the user opens history to
+ * read, whereas an argument only has to be recognisable.
+ */
+ const val MAX_RESPONSE_LENGTH = 2000
+
+ /**
+ * Shortens a response to [MAX_RESPONSE_LENGTH] characters, marking the cut the same way
+ * argument values are marked.
+ */
+ fun truncateResponse(response: String): String = if (response.length <= MAX_RESPONSE_LENGTH) {
+ response
+ } else {
+ response.take(MAX_RESPONSE_LENGTH) + McpCallArgument.TRUNCATION_MARKER
+ }
+ }
+}
+
/**
* Live view of what AI agents are doing through the MCP server, so the UI can tell the user when
* something other than themselves is driving the debugger.
@@ -33,15 +105,25 @@ data class McpActivity(
val startedCount: Long,
/** The most recently started tool call, kept so the UI can name and attribute it. */
val lastStartedInvocation: McpToolInvocation?,
+ /**
+ * Tool calls that have already completed, newest first, capped at [MAX_RECENT_CALLS]. This is a
+ * live troubleshooting aid rather than an audit log, so the oldest entries are dropped once the
+ * cap is reached.
+ */
+ val recentCalls: ImmutableList,
) {
val hasConnectedClient: Boolean get() = connectedClientCount > 0
companion object {
+ /** How many completed calls [recentCalls] keeps before dropping the oldest. */
+ const val MAX_RECENT_CALLS = 100
+
val Idle = McpActivity(
connectedClientCount = 0,
runningInvocations = persistentListOf(),
startedCount = 0,
lastStartedInvocation = null,
+ recentCalls = persistentListOf(),
)
}
}
diff --git a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
index bc257c8d..fc77fab2 100644
--- a/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
+++ b/jetwhale-host/core/model/src/main/kotlin/com/kitakkun/jetwhale/host/model/McpActivityRepository.kt
@@ -19,11 +19,27 @@ interface McpActivityRepository {
/**
* Records the start of a tool call.
*
+ * @param arguments the arguments the call was made with, already rendered to strings. Values are
+ * shortened to [McpCallArgument.MAX_VALUE_LENGTH] here, so nothing large is retained for as long
+ * as the call stays in history.
* @return the invocation id to hand back to [toolInvocationFinished] once the call completes.
*/
- fun toolInvocationStarted(toolName: String, pluginId: String?, sessionId: String?): Long
+ fun toolInvocationStarted(
+ toolName: String,
+ pluginId: String?,
+ sessionId: String?,
+ arguments: Map,
+ ): Long
- fun toolInvocationFinished(invocationId: Long)
+ /**
+ * Records the completion of a tool call and appends it to the recent-call history.
+ *
+ * @param failed true when the handler threw instead of returning a result.
+ * @param response what the call produced, already rendered to a string: the tool's result, or
+ * the failure message when it threw. Shortened to [McpCallRecord.MAX_RESPONSE_LENGTH] here. Pass
+ * an empty string when the call produced nothing to show.
+ */
+ fun toolInvocationFinished(invocationId: Long, failed: Boolean, response: String)
/** Drops all recorded activity, so a server restart does not inherit stale connection counts. */
fun clear()