feat: MCP server error handling

This commit is contained in:
Louis 2025-08-20 23:42:12 +07:00
parent f7df8d2a38
commit 6850dda108
No known key found for this signature in database
GPG Key ID: 44FA9F4D33C37DE2
7 changed files with 180 additions and 36 deletions

View File

@ -47,6 +47,8 @@ test: lint
yarn copy:assets:tauri
yarn build:icon
cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1
cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml
cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml
# Builds and publishes the app
build-and-publish: install-and-build

View File

@ -44,7 +44,7 @@ jan-utils = { path = "./utils" }
libloading = "0.8.7"
log = "0.4"
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "209dbac50f51737ad953c3a2c8e28f3619b6c277", features = [
rmcp = { version = "0.6.0", features = [
"client",
"transport-sse-client",
"transport-streamable-http-client",

View File

@ -7,10 +7,11 @@ use rmcp::{
ServiceExt,
};
use serde_json::Value;
use std::{collections::HashMap, env, sync::Arc, time::Duration};
use std::{collections::HashMap, env, process::Stdio, sync::Arc, time::Duration};
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
use tauri_plugin_http::reqwest;
use tokio::{
io::AsyncReadExt,
process::Command,
sync::Mutex,
time::{sleep, timeout},
@ -647,23 +648,8 @@ async fn schedule_mcp_start_task<R: Runtime>(
{
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW: prevents shell window on Windows
}
let app_path_str = app_path.to_str().unwrap().to_string();
let log_file_path = format!("{}/logs/app.log", app_path_str);
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_file_path)
{
Ok(file) => {
cmd.stderr(std::process::Stdio::from(file));
}
Err(err) => {
log::error!("Failed to open log file: {}", err);
}
};
cmd.kill_on_drop(true);
log::trace!("Command: {cmd:#?}");
config_params
.args
@ -678,26 +664,42 @@ async fn schedule_mcp_start_task<R: Runtime>(
}
});
let process = TokioChildProcess::new(cmd).map_err(|e| {
log::error!("Failed to run command {name}: {e}");
format!("Failed to run command {name}: {e}")
})?;
let (process, stderr) = TokioChildProcess::builder(cmd)
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
log::error!("Failed to run command {name}: {e}");
format!("Failed to run command {name}: {e}")
})?;
let service = ()
.serve(process)
.await
.map_err(|e| format!("Failed to start MCP server {name}: {e}"))?;
.map_err(|e| format!("Failed to start MCP server {name}: {e}"));
// Get peer info and clone the needed values before moving the service
let server_info = service.peer_info();
log::trace!("Connected to server: {server_info:#?}");
// Now move the service into the HashMap
servers
.lock()
.await
.insert(name.clone(), RunningServiceEnum::NoInit(service));
log::info!("Server {name} started successfully.");
match service {
Ok(server) => {
log::trace!("Connected to server: {:#?}", server.peer_info());
servers
.lock()
.await
.insert(name.clone(), RunningServiceEnum::NoInit(server));
log::info!("Server {name} started successfully.");
}
Err(_) => {
let mut buffer = String::new();
let error = match stderr
.expect("stderr must be piped")
.read_to_string(&mut buffer)
.await
{
Ok(_) => format!("Failed to start MCP server {name}: {buffer}"),
Err(_) => format!("Failed to read MCP server {name} stderr"),
};
log::error!("{error}");
return Err(error);
}
}
// Wait a short time to verify the server is stable before marking as connected
// This prevents race conditions where the server quits immediately
@ -754,7 +756,7 @@ pub fn extract_command_args(config: &Value) -> Option<McpServerConfig> {
command,
args,
envs,
headers
headers,
})
}

View File

@ -0,0 +1,123 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'
import { IconCopy, IconCopyCheck } from '@tabler/icons-react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { toast } from 'sonner'
import { useState } from 'react'
import { useAppState } from '@/hooks/useAppState'
export default function ErrorDialog() {
const { t } = useTranslation()
const { errorMessage, setErrorMessage } = useAppState()
const [isCopying, setIsCopying] = useState(false)
const [isDetailExpanded, setIsDetailExpanded] = useState(true)
const handleCopy = async () => {
setIsCopying(true)
try {
await navigator.clipboard.writeText(errorMessage?.message ?? '')
toast.success('Copy successful', {
id: 'copy-model',
description: 'Model load error information copied to clipboard',
})
} catch {
toast.error('Failed to copy', {
id: 'copy-model-error',
description: 'Failed to copy error information to clipboard',
})
} finally {
setTimeout(() => setIsCopying(false), 2000)
}
}
const handleDialogOpen = (open: boolean) => {
setErrorMessage(open ? errorMessage : undefined)
}
return (
<Dialog open={!!errorMessage} onOpenChange={handleDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<div className="flex items-start gap-3">
<div className="shrink-0">
<AlertTriangle className="size-4 text-destructive" />
</div>
<div>
<DialogTitle>{t('common:error')}</DialogTitle>
<DialogDescription className="mt-1 text-main-view-fg/70">
{errorMessage?.title ?? 'Something went wrong'}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="bg-main-view-fg/2 p-2 border border-main-view-fg/5 rounded-lg space-y-2">
<div>
<button
onClick={() => setIsDetailExpanded(!isDetailExpanded)}
className="flex items-center gap-1 text-sm text-main-view-fg/60 hover:text-main-view-fg/80 transition-colors cursor-pointer"
>
{isDetailExpanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
Details
</button>
{isDetailExpanded && (
<div
className="mt-2 text-sm text-main-view-fg/70 leading-relaxed max-h-[150px] overflow-y-auto break-all bg-main-view-fg/10 p-2 rounded border border-main-view-fg/5"
ref={(el) => {
if (el) {
el.scrollTop = el.scrollHeight
}
}}
>
{errorMessage?.message}
</div>
)}
</div>
<span className="text-sm text-main-view-fg/60">{errorMessage?.subtitle}</span>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-right">
<Button
variant="link"
onClick={() => handleDialogOpen(false)}
className="flex-1 text-right sm:flex-none"
>
{t('common:cancel')}
</Button>
<Button
variant="link"
onClick={() => handleCopy()}
disabled={isCopying}
autoFocus
className="flex-1 text-right sm:flex-none border border-main-view-fg/20 !px-2"
>
{isCopying ? (
<>
<IconCopyCheck className="text-accent" />
{t('common:copied')}
</>
) : (
<>
<IconCopy />
{t('common:copy')}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -4,6 +4,12 @@ import { MCPTool } from '@/types/completion'
import { useAssistant } from './useAssistant'
import { ChatCompletionMessageToolCall } from 'openai/resources'
type AppErrorMessage = {
message?: string
title?: string
subtitle: string
}
type AppState = {
streamingContent?: ThreadMessage
loadingModel?: boolean
@ -13,6 +19,7 @@ type AppState = {
tokenSpeed?: TokenSpeed
currentToolCall?: ChatCompletionMessageToolCall
showOutOfContextDialog?: boolean
errorMessage?: AppErrorMessage
cancelToolCall?: () => void
setServerStatus: (value: 'running' | 'stopped' | 'pending') => void
updateStreamingContent: (content: ThreadMessage | undefined) => void
@ -26,6 +33,7 @@ type AppState = {
resetTokenSpeed: () => void
setOutOfContextDialog: (show: boolean) => void
setCancelToolCall: (cancel: (() => void) | undefined) => void
setErrorMessage: (error: AppErrorMessage | undefined) => void
}
export const useAppState = create<AppState>()((set) => ({
@ -120,4 +128,9 @@ export const useAppState = create<AppState>()((set) => ({
cancelToolCall: cancel,
}))
},
setErrorMessage: (error) => {
set(() => ({
errorMessage: error,
}))
},
}))

View File

@ -29,6 +29,7 @@ import {
import { useCallback, useEffect } from 'react'
import GlobalError from '@/containers/GlobalError'
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
export const Route = createRootRoute({
component: RootLayout,
@ -203,6 +204,7 @@ function RootLayout() {
{/* <TanStackRouterDevtools position="bottom-right" /> */}
<ToolApproval />
<LoadModelErrorDialog />
<ErrorDialog />
<OutOfContextPromiseModal />
</TranslationProvider>
</Fragment>

View File

@ -21,6 +21,7 @@ import { useToolApproval } from '@/hooks/useToolApproval'
import { toast } from 'sonner'
import { invoke } from '@tauri-apps/api/core'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useAppState } from '@/hooks/useAppState'
// Function to mask sensitive values
const maskSensitiveValue = (value: string) => {
@ -120,6 +121,7 @@ function MCPServers() {
const [loadingServers, setLoadingServers] = useState<{
[key: string]: boolean
}>({})
const { setErrorMessage } = useAppState()
const handleOpenDialog = (serverKey?: string) => {
if (serverKey) {
@ -247,13 +249,13 @@ function MCPServers() {
getConnectedServers().then(setConnectedServers)
})
.catch((error) => {
console.log(error, 'error.mcp')
editServer(serverKey, {
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
active: false,
})
toast.error(error, {
description: t('mcp-servers:checkParams'),
setErrorMessage({
message: error,
subtitle: t('mcp-servers:checkParams'),
})
})
.finally(() => {