Revert "fix: clarify missing MCP stdio command errors (#5992)"
This reverts commit 0c771e4a77.
This commit is contained in:
@@ -15,7 +15,6 @@ from tenacity import (
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.utils.log_pipe import LogPipe
|
||||
from astrbot.dashboard.shared import MCP_STDIO_COMMAND_NOT_FOUND
|
||||
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
@@ -37,44 +36,6 @@ except (ModuleNotFoundError, ImportError):
|
||||
)
|
||||
|
||||
|
||||
class MCPStdioCommandNotFoundError(Exception):
|
||||
"""Raised when the configured stdio MCP command cannot be started."""
|
||||
|
||||
code = MCP_STDIO_COMMAND_NOT_FOUND
|
||||
|
||||
def __init__(
|
||||
self, command: str | None, original_error: Exception | None = None
|
||||
) -> None:
|
||||
self.command = str(command or "").strip() or "<unknown>"
|
||||
self.raw_error = str(original_error).strip() if original_error else ""
|
||||
super().__init__(
|
||||
_build_missing_stdio_command_message(self.command, self.raw_error or None)
|
||||
)
|
||||
|
||||
def to_response_data(self) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"code": self.code,
|
||||
"command": self.command,
|
||||
"raw_error": self.raw_error,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _build_missing_stdio_command_message(
|
||||
command: str | None, original_error: str | None = None
|
||||
) -> str:
|
||||
normalized_command = str(command or "").strip() or "<unknown>"
|
||||
sections = [
|
||||
"Unable to start the MCP stdio server",
|
||||
f"Command '{normalized_command}' was not found.",
|
||||
"Install the command, or set 'command' to the full executable path and ensure it is available in PATH.",
|
||||
]
|
||||
if original_error is not None:
|
||||
sections.append(f"Original error: {original_error!s}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format"""
|
||||
if config.get("mcpServers"):
|
||||
@@ -257,20 +218,17 @@ class MCPClient:
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
|
||||
try:
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
), # type: ignore
|
||||
),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise MCPStdioCommandNotFoundError(cfg.get("command"), exc) from exc
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
), # type: ignore
|
||||
),
|
||||
)
|
||||
|
||||
# Create a new client session
|
||||
self.session = await self.exit_stack.enter_async_context(
|
||||
|
||||
@@ -45,10 +45,9 @@ class Response:
|
||||
message: str | None = None
|
||||
data: dict | list | None = None
|
||||
|
||||
def error(self, message: str, data: dict | list | None = None):
|
||||
def error(self, message: str):
|
||||
self.status = "error"
|
||||
self.message = message
|
||||
self.data = data
|
||||
return self
|
||||
|
||||
def ok(self, data: dict | list | None = None, message: str | None = None):
|
||||
|
||||
@@ -3,18 +3,13 @@ import traceback
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.mcp_client import MCPStdioCommandNotFoundError, MCPTool
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.star import star_map
|
||||
from astrbot.dashboard.shared import MCP_TEST_CONNECTION_FAILED
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
MCP_TEST_CONNECTION_ERROR_MESSAGE = "Unable to test the MCP connection."
|
||||
MCP_TEST_CONNECTION_DETAILS_MESSAGE = (
|
||||
"Unable to test the MCP connection. Review the details below."
|
||||
)
|
||||
|
||||
|
||||
class EmptyMcpServersError(ValueError):
|
||||
@@ -75,32 +70,6 @@ class ToolsRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
def _build_mcp_test_error_response(self, error: Exception) -> dict:
|
||||
if isinstance(error, MCPStdioCommandNotFoundError):
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
MCP_TEST_CONNECTION_DETAILS_MESSAGE,
|
||||
error.to_response_data(),
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
msg = str(error).strip() or MCP_TEST_CONNECTION_ERROR_MESSAGE
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
MCP_TEST_CONNECTION_ERROR_MESSAGE,
|
||||
{
|
||||
"error": {
|
||||
"code": MCP_TEST_CONNECTION_FAILED,
|
||||
"detail": msg,
|
||||
}
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_mcp_servers(self):
|
||||
try:
|
||||
config = self.tool_mgr.load_mcp_config()
|
||||
@@ -454,7 +423,7 @@ class ToolsRoute(Route):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return self._build_mcp_test_error_response(e)
|
||||
return Response().error(f"Failed to test MCP connection: {e!s}").__dict__
|
||||
|
||||
async def get_tool_list(self):
|
||||
"""Get all registered tools."""
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from .mcp_test_connection_error_codes import (
|
||||
MCP_STDIO_COMMAND_NOT_FOUND,
|
||||
MCP_TEST_CONNECTION_FAILED,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MCP_STDIO_COMMAND_NOT_FOUND",
|
||||
"MCP_TEST_CONNECTION_FAILED",
|
||||
]
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"MCP_STDIO_COMMAND_NOT_FOUND": "mcp_stdio_command_not_found",
|
||||
"MCP_TEST_CONNECTION_FAILED": "mcp_test_connection_failed"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_ERROR_CODES_PATH = Path(__file__).with_name("mcp_test_connection_error_codes.json")
|
||||
_ERROR_CODES = json.loads(_ERROR_CODES_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
MCP_STDIO_COMMAND_NOT_FOUND = _ERROR_CODES["MCP_STDIO_COMMAND_NOT_FOUND"]
|
||||
MCP_TEST_CONNECTION_FAILED = _ERROR_CODES["MCP_TEST_CONNECTION_FAILED"]
|
||||
@@ -129,30 +129,9 @@
|
||||
</div>
|
||||
|
||||
</v-form>
|
||||
<v-alert
|
||||
v-if="addServerDialogFeedback"
|
||||
:type="addServerDialogFeedback.type"
|
||||
:icon="addServerDialogFeedback.icon"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
density="comfortable"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="text-subtitle-2 font-weight-medium">
|
||||
{{ addServerDialogFeedback.title }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(detail, index) in addServerDialogFeedback.details"
|
||||
:key="index"
|
||||
class="text-body-2"
|
||||
:class="index === 0 ? 'mt-2' : 'mt-1'"
|
||||
>
|
||||
{{ detail }}
|
||||
</div>
|
||||
<div v-if="addServerDialogFeedback.rawError" class="text-caption text-medium-emphasis mt-3">
|
||||
{{ tm('dialogs.addServer.feedback.rawError') }} {{ addServerDialogFeedback.rawError }}
|
||||
</div>
|
||||
</v-alert>
|
||||
<div style="margin-top: 8px;">
|
||||
<small>{{ addServerDialogMessage }}</small>
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
|
||||
@@ -238,7 +217,6 @@
|
||||
import axios from 'axios';
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import { MCP_TEST_CONNECTION_ERROR_CODES } from '@/constants/mcpTestConnectionErrorCodes';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
@@ -266,7 +244,7 @@ export default {
|
||||
mcpServerProviderList: ['modelscope'],
|
||||
mcpProviderToken: '',
|
||||
showSyncMcpServerDialog: false,
|
||||
addServerDialogFeedback: null,
|
||||
addServerDialogMessage: '',
|
||||
loading: false,
|
||||
loadingGettingServers: false,
|
||||
mcpServerUpdateLoaders: {},
|
||||
@@ -403,7 +381,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.showMcpServerDialog = false;
|
||||
this.clearAddServerDialogFeedback();
|
||||
this.addServerDialogMessage = '';
|
||||
this.getServers();
|
||||
this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
|
||||
this.resetForm();
|
||||
@@ -467,73 +445,14 @@ export default {
|
||||
},
|
||||
closeServerDialog() {
|
||||
this.showMcpServerDialog = false;
|
||||
this.clearAddServerDialogFeedback();
|
||||
this.addServerDialogMessage = '';
|
||||
this.resetForm();
|
||||
},
|
||||
setAddServerDialogFeedback(feedback = null) {
|
||||
this.addServerDialogFeedback = feedback;
|
||||
},
|
||||
clearAddServerDialogFeedback() {
|
||||
this.setAddServerDialogFeedback(null);
|
||||
},
|
||||
buildAddServerDialogErrorFeedback(message, errorData = null) {
|
||||
const normalizedMessage = String(message || '').trim();
|
||||
if (errorData?.code === MCP_TEST_CONNECTION_ERROR_CODES.STDIO_COMMAND_NOT_FOUND) {
|
||||
return {
|
||||
type: 'error',
|
||||
icon: 'mdi-alert-circle',
|
||||
title: this.tm('dialogs.addServer.feedback.stdioCommandNotFound.title'),
|
||||
details: [
|
||||
this.tm('dialogs.addServer.feedback.stdioCommandNotFound.reason', {
|
||||
command: errorData.command || '<unknown>'
|
||||
}),
|
||||
this.tm('dialogs.addServer.feedback.stdioCommandNotFound.action')
|
||||
],
|
||||
rawError: errorData.raw_error || ''
|
||||
};
|
||||
}
|
||||
if (errorData?.code === MCP_TEST_CONNECTION_ERROR_CODES.TEST_CONNECTION_FAILED) {
|
||||
const detail = String(errorData.detail || '').trim();
|
||||
return {
|
||||
type: 'error',
|
||||
icon: 'mdi-alert-circle',
|
||||
title: this.tm('dialogs.addServer.feedback.errorTitle'),
|
||||
details: [detail || this.tm('dialogs.addServer.feedback.genericDetail')],
|
||||
rawError: errorData.raw_error || ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'error',
|
||||
icon: 'mdi-alert-circle',
|
||||
title: this.tm('dialogs.addServer.feedback.errorTitle'),
|
||||
details: [
|
||||
normalizedMessage || this.tm('dialogs.addServer.feedback.genericDetail')
|
||||
],
|
||||
rawError: errorData?.raw_error || ''
|
||||
};
|
||||
},
|
||||
buildAddServerDialogSuccessFeedback(message, tools = '') {
|
||||
const details = [];
|
||||
if (message) {
|
||||
details.push(message);
|
||||
}
|
||||
if (tools) {
|
||||
details.push(this.tm('dialogs.addServer.feedback.availableTools', { tools }));
|
||||
}
|
||||
return {
|
||||
type: 'success',
|
||||
icon: 'mdi-check-circle',
|
||||
title: this.tm('dialogs.addServer.feedback.successTitle'),
|
||||
details,
|
||||
rawError: ''
|
||||
};
|
||||
},
|
||||
testServerConnection() {
|
||||
if (!this.validateJson()) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
this.clearAddServerDialogFeedback();
|
||||
let configObj;
|
||||
try {
|
||||
configObj = JSON.parse(this.serverConfigJson);
|
||||
@@ -547,33 +466,11 @@ export default {
|
||||
})
|
||||
.then(response => {
|
||||
this.loading = false;
|
||||
if (response.data.status === 'error') {
|
||||
this.showError(
|
||||
response.data.message || this.tm('dialogs.addServer.feedback.genericDetail'),
|
||||
{
|
||||
inlineDialog: true,
|
||||
errorData: response.data.data?.error
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tools = Array.isArray(response.data.data)
|
||||
? response.data.data.join(', ')
|
||||
: response.data.data;
|
||||
this.setAddServerDialogFeedback(
|
||||
this.buildAddServerDialogSuccessFeedback(response.data.message, tools)
|
||||
);
|
||||
this.addServerDialogMessage = `${response.data.message} (tools: ${response.data.data})`;
|
||||
})
|
||||
.catch(error => {
|
||||
this.loading = false;
|
||||
this.showError(
|
||||
error.response?.data?.message || error.message || this.tm('dialogs.addServer.feedback.genericDetail'),
|
||||
{
|
||||
inlineDialog: true,
|
||||
errorData: error.response?.data?.data?.error
|
||||
}
|
||||
);
|
||||
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
|
||||
});
|
||||
},
|
||||
resetForm() {
|
||||
@@ -592,13 +489,7 @@ export default {
|
||||
this.save_message_success = 'success';
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
showError(message, options = {}) {
|
||||
if (options.inlineDialog) {
|
||||
this.setAddServerDialogFeedback(
|
||||
this.buildAddServerDialogErrorFeedback(message, options.errorData)
|
||||
);
|
||||
return;
|
||||
}
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = 'error';
|
||||
this.save_message_snack = true;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import errorCodes from '@dashboard-shared/mcp_test_connection_error_codes.json';
|
||||
|
||||
export const MCP_TEST_CONNECTION_ERROR_CODES = {
|
||||
STDIO_COMMAND_NOT_FOUND: errorCodes.MCP_STDIO_COMMAND_NOT_FOUND,
|
||||
TEST_CONNECTION_FAILED: errorCodes.MCP_TEST_CONNECTION_FAILED
|
||||
};
|
||||
@@ -86,18 +86,6 @@
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "Please configure tool call timeout separately in the configuration page"
|
||||
},
|
||||
"feedback": {
|
||||
"successTitle": "Connection test passed",
|
||||
"errorTitle": "Connection test failed",
|
||||
"genericDetail": "The connection test did not complete successfully.",
|
||||
"availableTools": "Available tools: {tools}",
|
||||
"rawError": "Original error:",
|
||||
"stdioCommandNotFound": {
|
||||
"title": "Unable to start the MCP stdio server",
|
||||
"reason": "Command '{command}' was not found.",
|
||||
"action": "Install the command, or set 'command' to the full executable path and ensure it is available in PATH."
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
|
||||
@@ -86,18 +86,6 @@
|
||||
},
|
||||
"tips": {
|
||||
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置"
|
||||
},
|
||||
"feedback": {
|
||||
"successTitle": "连接测试通过",
|
||||
"errorTitle": "连接测试失败",
|
||||
"genericDetail": "连接测试未能成功完成。",
|
||||
"availableTools": "可用工具:{tools}",
|
||||
"rawError": "原始错误:",
|
||||
"stdioCommandNotFound": {
|
||||
"title": "无法启动 MCP stdio 服务",
|
||||
"reason": "未找到命令 “{command}”。",
|
||||
"action": "请先安装该命令,或将 command 修改为本机可执行文件的完整路径,并确认它已加入 PATH。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverDetail": {
|
||||
|
||||
@@ -20,10 +20,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
mermaid: 'mermaid/dist/mermaid.js',
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@dashboard-shared': fileURLToPath(
|
||||
new URL('../astrbot/dashboard/shared', import.meta.url)
|
||||
)
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
css: {
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ allow-direct-references = true
|
||||
|
||||
# Include bundled dashboard dist even though it is not tracked by VCS.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
artifacts = ["astrbot/dashboard/dist/**", "astrbot/dashboard/shared/*.json"]
|
||||
artifacts = ["astrbot/dashboard/dist/**"]
|
||||
|
||||
# Custom build hook: builds the Vue dashboard and copies dist into the package.
|
||||
[tool.hatch.build.hooks.custom]
|
||||
|
||||
+1
-86
@@ -3,7 +3,6 @@ import io
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -17,11 +16,6 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.star.star_handler import star_handlers_registry
|
||||
from astrbot.core.agent import mcp_client as mcp_client_module
|
||||
from astrbot.dashboard.shared import (
|
||||
MCP_STDIO_COMMAND_NOT_FOUND,
|
||||
MCP_TEST_CONNECTION_FAILED,
|
||||
)
|
||||
from astrbot.dashboard.routes.plugin import PluginRoute
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
from tests.fixtures.helpers import (
|
||||
@@ -276,89 +270,10 @@ async def test_commands_api(app: Quart, authenticated_header: dict):
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
# conflicts is a list
|
||||
assert isinstance(data["data"], list)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_test_connection_returns_clear_missing_stdio_command_message(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
monkeypatch,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_stdio_client(*args, **kwargs):
|
||||
raise FileNotFoundError(2, "系统找不到指定的文件。")
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(mcp_client_module.mcp, "stdio_client", fake_stdio_client)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/tools/mcp/test",
|
||||
json={
|
||||
"mcp_server_config": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
}
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == "Unable to test the MCP connection. Review the details below."
|
||||
assert data["data"] == {
|
||||
"error": {
|
||||
"code": MCP_STDIO_COMMAND_NOT_FOUND,
|
||||
"command": "uvx",
|
||||
"raw_error": "[Errno 2] 系统找不到指定的文件。",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_test_connection_uses_fallback_for_blank_error_message(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
|
||||
async def raise_blank_error(*args, **kwargs):
|
||||
raise Exception(" ")
|
||||
|
||||
monkeypatch.setattr(
|
||||
core_lifecycle_td.provider_manager.llm_tools,
|
||||
"test_mcp_server_connection",
|
||||
raise_blank_error,
|
||||
)
|
||||
|
||||
response = await test_client.post(
|
||||
"/api/tools/mcp/test",
|
||||
json={
|
||||
"mcp_server_config": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
}
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert data["message"] == "Unable to test the MCP connection."
|
||||
assert data["data"] == {
|
||||
"error": {
|
||||
"code": MCP_TEST_CONNECTION_FAILED,
|
||||
"detail": "Unable to test the MCP connection.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_update(
|
||||
app: Quart,
|
||||
|
||||
Reference in New Issue
Block a user