mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
tui: show real-time MCP server status in home screen with connection state indicators
- 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:
parent
021d1d1b5e
commit
c7c2d358e0
8 changed files with 222 additions and 102 deletions
15
logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json
Normal file
15
logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json
Normal 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"
|
||||
}
|
||||
38
logs/mcp-puppeteer-2025-10-07.log
Normal file
38
logs/mcp-puppeteer-2025-10-07.log
Normal 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"}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue