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 copy:assets:tauri
|
||||||
yarn build:icon
|
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/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
|
# Builds and publishes the app
|
||||||
build-and-publish: install-and-build
|
build-and-publish: install-and-build
|
||||||
|
|||||||
@ -44,7 +44,7 @@ jan-utils = { path = "./utils" }
|
|||||||
libloading = "0.8.7"
|
libloading = "0.8.7"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = { version = "0.11", features = ["json", "blocking", "stream"] }
|
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",
|
"client",
|
||||||
"transport-sse-client",
|
"transport-sse-client",
|
||||||
"transport-streamable-http-client",
|
"transport-streamable-http-client",
|
||||||
|
|||||||
@ -7,10 +7,11 @@ use rmcp::{
|
|||||||
ServiceExt,
|
ServiceExt,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
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::{AppHandle, Emitter, Manager, Runtime, State};
|
||||||
use tauri_plugin_http::reqwest;
|
use tauri_plugin_http::reqwest;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
|
io::AsyncReadExt,
|
||||||
process::Command,
|
process::Command,
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
time::{sleep, timeout},
|
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
|
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);
|
cmd.kill_on_drop(true);
|
||||||
log::trace!("Command: {cmd:#?}");
|
|
||||||
|
|
||||||
config_params
|
config_params
|
||||||
.args
|
.args
|
||||||
@ -678,26 +664,42 @@ async fn schedule_mcp_start_task<R: Runtime>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let process = TokioChildProcess::new(cmd).map_err(|e| {
|
let (process, stderr) = TokioChildProcess::builder(cmd)
|
||||||
log::error!("Failed to run command {name}: {e}");
|
.stderr(Stdio::piped())
|
||||||
format!("Failed to run command {name}: {e}")
|
.spawn()
|
||||||
})?;
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to run command {name}: {e}");
|
||||||
|
format!("Failed to run command {name}: {e}")
|
||||||
|
})?;
|
||||||
|
|
||||||
let service = ()
|
let service = ()
|
||||||
.serve(process)
|
.serve(process)
|
||||||
.await
|
.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
|
match service {
|
||||||
let server_info = service.peer_info();
|
Ok(server) => {
|
||||||
log::trace!("Connected to server: {server_info:#?}");
|
log::trace!("Connected to server: {:#?}", server.peer_info());
|
||||||
|
servers
|
||||||
// Now move the service into the HashMap
|
.lock()
|
||||||
servers
|
.await
|
||||||
.lock()
|
.insert(name.clone(), RunningServiceEnum::NoInit(server));
|
||||||
.await
|
log::info!("Server {name} started successfully.");
|
||||||
.insert(name.clone(), RunningServiceEnum::NoInit(service));
|
}
|
||||||
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
|
// Wait a short time to verify the server is stable before marking as connected
|
||||||
// This prevents race conditions where the server quits immediately
|
// This prevents race conditions where the server quits immediately
|
||||||
@ -754,7 +756,7 @@ pub fn extract_command_args(config: &Value) -> Option<McpServerConfig> {
|
|||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
envs,
|
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 { useAssistant } from './useAssistant'
|
||||||
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
import { ChatCompletionMessageToolCall } from 'openai/resources'
|
||||||
|
|
||||||
|
type AppErrorMessage = {
|
||||||
|
message?: string
|
||||||
|
title?: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
type AppState = {
|
type AppState = {
|
||||||
streamingContent?: ThreadMessage
|
streamingContent?: ThreadMessage
|
||||||
loadingModel?: boolean
|
loadingModel?: boolean
|
||||||
@ -13,6 +19,7 @@ type AppState = {
|
|||||||
tokenSpeed?: TokenSpeed
|
tokenSpeed?: TokenSpeed
|
||||||
currentToolCall?: ChatCompletionMessageToolCall
|
currentToolCall?: ChatCompletionMessageToolCall
|
||||||
showOutOfContextDialog?: boolean
|
showOutOfContextDialog?: boolean
|
||||||
|
errorMessage?: AppErrorMessage
|
||||||
cancelToolCall?: () => void
|
cancelToolCall?: () => void
|
||||||
setServerStatus: (value: 'running' | 'stopped' | 'pending') => void
|
setServerStatus: (value: 'running' | 'stopped' | 'pending') => void
|
||||||
updateStreamingContent: (content: ThreadMessage | undefined) => void
|
updateStreamingContent: (content: ThreadMessage | undefined) => void
|
||||||
@ -26,6 +33,7 @@ type AppState = {
|
|||||||
resetTokenSpeed: () => void
|
resetTokenSpeed: () => void
|
||||||
setOutOfContextDialog: (show: boolean) => void
|
setOutOfContextDialog: (show: boolean) => void
|
||||||
setCancelToolCall: (cancel: (() => void) | undefined) => void
|
setCancelToolCall: (cancel: (() => void) | undefined) => void
|
||||||
|
setErrorMessage: (error: AppErrorMessage | undefined) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>()((set) => ({
|
export const useAppState = create<AppState>()((set) => ({
|
||||||
@ -120,4 +128,9 @@ export const useAppState = create<AppState>()((set) => ({
|
|||||||
cancelToolCall: cancel,
|
cancelToolCall: cancel,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
setErrorMessage: (error) => {
|
||||||
|
set(() => ({
|
||||||
|
errorMessage: error,
|
||||||
|
}))
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import GlobalError from '@/containers/GlobalError'
|
import GlobalError from '@/containers/GlobalError'
|
||||||
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
import { GlobalEventHandler } from '@/providers/GlobalEventHandler'
|
||||||
|
import ErrorDialog from '@/containers/dialogs/ErrorDialog'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@ -203,6 +204,7 @@ function RootLayout() {
|
|||||||
{/* <TanStackRouterDevtools position="bottom-right" /> */}
|
{/* <TanStackRouterDevtools position="bottom-right" /> */}
|
||||||
<ToolApproval />
|
<ToolApproval />
|
||||||
<LoadModelErrorDialog />
|
<LoadModelErrorDialog />
|
||||||
|
<ErrorDialog />
|
||||||
<OutOfContextPromiseModal />
|
<OutOfContextPromiseModal />
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useToolApproval } from '@/hooks/useToolApproval'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||||
|
import { useAppState } from '@/hooks/useAppState'
|
||||||
|
|
||||||
// Function to mask sensitive values
|
// Function to mask sensitive values
|
||||||
const maskSensitiveValue = (value: string) => {
|
const maskSensitiveValue = (value: string) => {
|
||||||
@ -120,6 +121,7 @@ function MCPServers() {
|
|||||||
const [loadingServers, setLoadingServers] = useState<{
|
const [loadingServers, setLoadingServers] = useState<{
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}>({})
|
}>({})
|
||||||
|
const { setErrorMessage } = useAppState()
|
||||||
|
|
||||||
const handleOpenDialog = (serverKey?: string) => {
|
const handleOpenDialog = (serverKey?: string) => {
|
||||||
if (serverKey) {
|
if (serverKey) {
|
||||||
@ -247,13 +249,13 @@ function MCPServers() {
|
|||||||
getConnectedServers().then(setConnectedServers)
|
getConnectedServers().then(setConnectedServers)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log(error, 'error.mcp')
|
|
||||||
editServer(serverKey, {
|
editServer(serverKey, {
|
||||||
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
...(config ?? (mcpServers[serverKey] as MCPServerConfig)),
|
||||||
active: false,
|
active: false,
|
||||||
})
|
})
|
||||||
toast.error(error, {
|
setErrorMessage({
|
||||||
description: t('mcp-servers:checkParams'),
|
message: error,
|
||||||
|
subtitle: t('mcp-servers:checkParams'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user