feat: MCP server error handling
This commit is contained in:
parent
f7df8d2a38
commit
6850dda108
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
123
web-app/src/containers/dialogs/ErrorDialog.tsx
Normal file
123
web-app/src/containers/dialogs/ErrorDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
}))
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user