tui: show real-time MCP server status in home screen with connection state indicators
Some checks are pending
format / format (push) Waiting to run
snapshot / publish (push) Waiting to run
test / test (push) Waiting to run

- Displays MCP servers with visual status indicators (connected/failed/disabled)
- Shows error messages for failed connections to help users troubleshoot
- Replaces static help text with dynamic keybind display that updates based on user config
- Adds weather and puppeteer MCP servers to example configuration
This commit is contained in:
Dax Raad 2025-10-07 06:02:16 -04:00
parent 021d1d1b5e
commit c7c2d358e0
8 changed files with 222 additions and 102 deletions

View file

@ -0,0 +1,15 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}

View file

@ -0,0 +1,38 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}

View file

@ -1,3 +1,13 @@
{
"$schema": "https://opencode.ai/config.json"
"$schema": "https://opencode.ai/config.json",
"mcp": {
"weather": {
"type": "local",
"command": ["bun", "x", "@h1deya/mcp-server-weather"]
},
"puppeteer": {
"type": "local",
"command": ["buns", "x", "puppeteer-mcp-server"]
}
}
}

View file

@ -1,36 +1,77 @@
import { Installation } from "@/installation"
import { TextAttributes } from "@opentui/core"
import { Prompt } from "@tui/component/prompt"
import { For } from "solid-js"
import { createResource, For, Match, Show, Suspense, Switch, type ParentProps } from "solid-js"
import { Theme } from "@tui/context/theme"
import { useSDK } from "../context/sdk"
import { useKeybind } from "../context/keybind"
import type { KeybindsConfig } from "@opencode-ai/sdk"
export function Home() {
const sdk = useSDK()
const [mcp] = createResource(async () => {
const result = await sdk.mcp.status()
return result.data
})
return (
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2}>
<box>
<Logo />
<box paddingTop={2}>
<HelpRow slash="new">new session</HelpRow>
<HelpRow slash="help">show help</HelpRow>
<HelpRow slash="share">share session</HelpRow>
<HelpRow slash="models">list models</HelpRow>
<HelpRow slash="agents">list agents</HelpRow>
</box>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
<Logo />
<box width={39}>
<HelpRow keybind="command_list">Commands</HelpRow>
<HelpRow keybind="session_list">List sessions</HelpRow>
<HelpRow keybind="model_list">Switch model</HelpRow>
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
</box>
<box paddingTop={3} width="100%" maxWidth={75}>
<Suspense>
<Show when={Object.keys(mcp() ?? {}).length > 10}>
<box maxWidth={39}>
<For each={Object.entries(mcp() ?? {})}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: Theme.success,
failed: Theme.error,
disabled: Theme.textMuted,
}[item.status],
}}
>
</text>
<text wrapMode="word">
<b>{key}</b> <span style={{ fg: Theme.textMuted }}>(MCP)</span>{" "}
<span style={{ fg: Theme.textMuted }}>
<Switch>
<Match when={item.status === "connected"}>
<></>
</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
</Suspense>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt />
</box>
</box>
)
}
function HelpRow(props: { children: string; slash: string }) {
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
const keybind = useKeybind()
return (
<text>
<span style={{ bold: true, fg: Theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(19, " ")} </span>
<span style={{ fg: Theme.textMuted }}>ctrl+x n</span>
</text>
<box flexDirection="row" justifyContent="space-between" width="100%">
<text>{props.children}</text>
<text fg={Theme.primary}>{keybind.print(props.keybind)}</text>
</box>
)
}

View file

@ -6,8 +6,6 @@ import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "../util/error"
import z from "zod/v4"
import { Session } from "../session"
import { Bus } from "../bus"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
@ -21,14 +19,43 @@ export namespace MCP {
}),
)
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
export const Status = z
.discriminatedUnion("status", [
z
.object({
status: z.literal("connected"),
})
.meta({
ref: "MCPStatusConnected",
}),
z
.object({
status: z.literal("disabled"),
})
.meta({
ref: "MCPStatusDisabled",
}),
z
.object({
status: z.literal("failed"),
error: z.string(),
})
.meta({
ref: "MCPStatusFailed",
}),
])
.meta({
ref: "MCPStatus",
})
export type Status = z.infer<typeof Status>
const state = Instance.state(
async () => {
const cfg = await Config.get()
const clients: {
[name: string]: MCPClient
} = {}
const clients: Record<string, Client> = {}
const status: Record<string, Status> = {}
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
@ -56,51 +83,35 @@ export namespace MCP {
]
let lastError: Error | undefined
for (const { name, transport } of transports) {
const client = await experimental_createMCPClient({
await experimental_createMCPClient({
name: "opencode",
transport,
}).catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
.then((client) => {
clients[key] = client
status[key] = {
status: "connected",
}
})
return null
})
if (client) {
log.debug("transport connection succeeded", {
key,
transport: name,
.catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
status[key] = {
status: "failed",
error: lastError.message,
}
})
clients[key] = client
break
}
}
if (!clients[key]) {
const errorMessage = lastError
? `MCP server ${key} failed to connect: ${lastError.message}`
: `MCP server ${key} failed to connect to ${mcp.url}`
log.error("remote mcp connection failed", {
key,
url: mcp.url,
error: lastError?.message,
})
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: errorMessage,
},
},
})
}
}
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const client = await experimental_createMCPClient({
await experimental_createMCPClient({
name: "opencode",
transport: new StdioClientTransport({
stderr: "ignore",
@ -112,43 +123,42 @@ export namespace MCP {
...mcp.environment,
},
}),
}).catch((error) => {
const errorMessage =
error instanceof Error
? `MCP server ${key} failed to start: ${error.message}`
: `MCP server ${key} failed to start`
log.error("local mcp startup failed", {
key,
command: mcp.command,
error: error instanceof Error ? error.message : String(error),
})
Bus.publish(Session.Event.Error, {
error: {
name: "UnknownError",
data: {
message: errorMessage,
},
},
})
return null
})
if (client) {
clients[key] = client
}
.then((client) => {
clients[key] = client
status[key] = {
status: "connected",
}
})
.catch((error) => {
log.error("local mcp startup failed", {
key,
command: mcp.command,
error: error instanceof Error ? error.message : String(error),
})
status[key] = {
status: "failed",
error: error instanceof Error ? error.message : String(error),
}
})
}
}
for (const [key, client] of Object.entries(clients)) {
const result = await withTimeout(client.tools(), 5000).catch(() => {})
if (!result) {
log.warn("mcp client verification failed, removing client", { key })
client.close()
delete clients[key]
status[key] = {
status: "failed",
error: "Failed to get tools",
}
}
}
return {
status,
clients,
config: cfg.mcp ?? {},
}
},
async (state) => {
@ -159,20 +169,7 @@ export namespace MCP {
)
export async function status() {
return state().then((state) => {
const result: Record<string, "connected" | "failed" | "disabled"> = {}
for (const [key, client] of Object.entries(state.config)) {
if (client.enabled === false) {
result[key] = "disabled"
continue
}
if (state.clients[key]) {
result[key] = "connected"
}
result[key] = "failed"
}
return result
})
return state().then((state) => state.status)
}
export async function clients() {

View file

@ -1194,7 +1194,7 @@ export namespace Server {
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.any()),
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},

View file

@ -27,7 +27,7 @@ export namespace Keybind {
let result = parts.join("+")
if (info.leader) {
result = result ? `<leader>,${result}` : `<leader>`
result = result ? `<leader> ${result}` : `<leader>`
}
return result

View file

@ -1005,6 +1005,21 @@ export type Agent = {
}
}
export type McpStatusConnected = {
status: "connected"
}
export type McpStatusDisabled = {
status: "disabled"
}
export type McpStatusFailed = {
status: "failed"
error: string
}
export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed
export type OAuth = {
type: "oauth"
refresh: string
@ -2086,9 +2101,13 @@ export type McpStatusResponses = {
/**
* MCP server status
*/
200: unknown
200: {
[key: string]: McpStatus
}
}
export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]
export type TuiAppendPromptData = {
body?: {
text: string