Revert "fix: clarify missing MCP stdio command errors (#5992)"

This reverts commit 0c771e4a77.
This commit is contained in:
邹永赫
2026-03-11 00:08:06 +09:00
parent 0c771e4a77
commit e25a1a42cf
13 changed files with 27 additions and 349 deletions
+11 -53
View File
@@ -15,7 +15,6 @@ from tenacity import (
from astrbot import logger from astrbot import logger
from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.utils.log_pipe import LogPipe from astrbot.core.utils.log_pipe import LogPipe
from astrbot.dashboard.shared import MCP_STDIO_COMMAND_NOT_FOUND
from .run_context import TContext from .run_context import TContext
from .tool import FunctionTool 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: def _prepare_config(config: dict) -> dict:
"""Prepare configuration, handle nested format""" """Prepare configuration, handle nested format"""
if config.get("mcpServers"): if config.get("mcpServers"):
@@ -257,20 +218,17 @@ class MCPClient:
# Handle MCP service error logs # Handle MCP service error logs
self.server_errlogs.append(msg) self.server_errlogs.append(msg)
try: stdio_transport = await self.exit_stack.enter_async_context(
stdio_transport = await self.exit_stack.enter_async_context( mcp.stdio_client(
mcp.stdio_client( server_params,
server_params, errlog=LogPipe(
errlog=LogPipe( level=logging.ERROR,
level=logging.ERROR, logger=logger,
logger=logger, identifier=f"MCPServer-{name}",
identifier=f"MCPServer-{name}", callback=callback,
callback=callback, ), # type: ignore
), # type: ignore ),
), )
)
except FileNotFoundError as exc:
raise MCPStdioCommandNotFoundError(cfg.get("command"), exc) from exc
# Create a new client session # Create a new client session
self.session = await self.exit_stack.enter_async_context( self.session = await self.exit_stack.enter_async_context(
+1 -2
View File
@@ -45,10 +45,9 @@ class Response:
message: str | None = None message: str | None = None
data: dict | list | 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.status = "error"
self.message = message self.message = message
self.data = data
return self return self
def ok(self, data: dict | list | None = None, message: str | None = None): def ok(self, data: dict | list | None = None, message: str | None = None):
+2 -33
View File
@@ -3,18 +3,13 @@ import traceback
from quart import request from quart import request
from astrbot.core import logger 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.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map from astrbot.core.star import star_map
from astrbot.dashboard.shared import MCP_TEST_CONNECTION_FAILED
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
DEFAULT_MCP_CONFIG = {"mcpServers": {}} 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): class EmptyMcpServersError(ValueError):
@@ -75,32 +70,6 @@ class ToolsRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return False 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): async def get_mcp_servers(self):
try: try:
config = self.tool_mgr.load_mcp_config() config = self.tool_mgr.load_mcp_config()
@@ -454,7 +423,7 @@ class ToolsRoute(Route):
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) 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): async def get_tool_list(self):
"""Get all registered tools.""" """Get all registered tools."""
-9
View File
@@ -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> </div>
</v-form> </v-form>
<v-alert <div style="margin-top: 8px;">
v-if="addServerDialogFeedback" <small>{{ addServerDialogMessage }}</small>
:type="addServerDialogFeedback.type" </div>
: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>
</v-card-text> </v-card-text>
@@ -238,7 +217,6 @@
import axios from 'axios'; import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'; import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue'; import ItemCard from '@/components/shared/ItemCard.vue';
import { MCP_TEST_CONNECTION_ERROR_CODES } from '@/constants/mcpTestConnectionErrorCodes';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import { import {
askForConfirmation as askForConfirmationDialog, askForConfirmation as askForConfirmationDialog,
@@ -266,7 +244,7 @@ export default {
mcpServerProviderList: ['modelscope'], mcpServerProviderList: ['modelscope'],
mcpProviderToken: '', mcpProviderToken: '',
showSyncMcpServerDialog: false, showSyncMcpServerDialog: false,
addServerDialogFeedback: null, addServerDialogMessage: '',
loading: false, loading: false,
loadingGettingServers: false, loadingGettingServers: false,
mcpServerUpdateLoaders: {}, mcpServerUpdateLoaders: {},
@@ -403,7 +381,7 @@ export default {
return; return;
} }
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.clearAddServerDialogFeedback(); this.addServerDialogMessage = '';
this.getServers(); this.getServers();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess')); this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm(); this.resetForm();
@@ -467,73 +445,14 @@ export default {
}, },
closeServerDialog() { closeServerDialog() {
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.clearAddServerDialogFeedback(); this.addServerDialogMessage = '';
this.resetForm(); 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() { testServerConnection() {
if (!this.validateJson()) { if (!this.validateJson()) {
return; return;
} }
this.loading = true; this.loading = true;
this.clearAddServerDialogFeedback();
let configObj; let configObj;
try { try {
configObj = JSON.parse(this.serverConfigJson); configObj = JSON.parse(this.serverConfigJson);
@@ -547,33 +466,11 @@ export default {
}) })
.then(response => { .then(response => {
this.loading = false; this.loading = false;
if (response.data.status === 'error') { this.addServerDialogMessage = `${response.data.message} (tools: ${response.data.data})`;
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)
);
}) })
.catch(error => { .catch(error => {
this.loading = false; this.loading = false;
this.showError( this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
error.response?.data?.message || error.message || this.tm('dialogs.addServer.feedback.genericDetail'),
{
inlineDialog: true,
errorData: error.response?.data?.data?.error
}
);
}); });
}, },
resetForm() { resetForm() {
@@ -592,13 +489,7 @@ export default {
this.save_message_success = 'success'; this.save_message_success = 'success';
this.save_message_snack = true; this.save_message_snack = true;
}, },
showError(message, options = {}) { showError(message) {
if (options.inlineDialog) {
this.setAddServerDialogFeedback(
this.buildAddServerDialogErrorFeedback(message, options.errorData)
);
return;
}
this.save_message = message; this.save_message = message;
this.save_message_success = 'error'; this.save_message_success = 'error';
this.save_message_snack = true; 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": { "tips": {
"timeoutConfig": "Please configure tool call timeout separately in the configuration page" "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": { "serverDetail": {
@@ -86,18 +86,6 @@
}, },
"tips": { "tips": {
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置" "timeoutConfig": "工具调用的超时时间请前往配置页面单独配置"
},
"feedback": {
"successTitle": "连接测试通过",
"errorTitle": "连接测试失败",
"genericDetail": "连接测试未能成功完成。",
"availableTools": "可用工具:{tools}",
"rawError": "原始错误:",
"stdioCommandNotFound": {
"title": "无法启动 MCP stdio 服务",
"reason": "未找到命令 “{command}”。",
"action": "请先安装该命令,或将 command 修改为本机可执行文件的完整路径,并确认它已加入 PATH。"
}
} }
}, },
"serverDetail": { "serverDetail": {
+1 -4
View File
@@ -20,10 +20,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
mermaid: 'mermaid/dist/mermaid.js', mermaid: 'mermaid/dist/mermaid.js',
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url))
'@dashboard-shared': fileURLToPath(
new URL('../astrbot/dashboard/shared', import.meta.url)
)
} }
}, },
css: { css: {
+1 -1
View File
@@ -116,7 +116,7 @@ allow-direct-references = true
# Include bundled dashboard dist even though it is not tracked by VCS. # Include bundled dashboard dist even though it is not tracked by VCS.
[tool.hatch.build.targets.wheel] [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. # Custom build hook: builds the Vue dashboard and copies dist into the package.
[tool.hatch.build.hooks.custom] [tool.hatch.build.hooks.custom]
+1 -86
View File
@@ -3,7 +3,6 @@ import io
import os import os
import sys import sys
import zipfile import zipfile
from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from types import SimpleNamespace 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.db.sqlite import SQLiteDatabase
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core.star.star_handler import star_handlers_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.routes.plugin import PluginRoute
from astrbot.dashboard.server import AstrBotDashboard from astrbot.dashboard.server import AstrBotDashboard
from tests.fixtures.helpers import ( from tests.fixtures.helpers import (
@@ -276,89 +270,10 @@ async def test_commands_api(app: Quart, authenticated_header: dict):
assert response.status_code == 200 assert response.status_code == 200
data = await response.get_json() data = await response.get_json()
assert data["status"] == "ok" assert data["status"] == "ok"
# conflicts is a list
assert isinstance(data["data"], 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 @pytest.mark.asyncio
async def test_check_update( async def test_check_update(
app: Quart, app: Quart,