feat(mcp): support mcp resources

This commit is contained in:
yang.xiang
2026-05-14 16:07:48 +08:00
committed by RockChinQ
parent 1c128a1524
commit 097219556c
14 changed files with 449 additions and 6 deletions
@@ -66,3 +66,25 @@ class MCPRouterGroup(group.RouterGroup):
server_data = await quart.request.json
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
return self.success(data={'task_id': task_id})
@self.route('/servers/<server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Get resources from an MCP server"""
try:
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
return self.success(data={'resources': resources})
except Exception as e:
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
@self.route('/servers/<server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Read a resource from an MCP server"""
data = await quart.request.json
uri = data.get('uri')
if not uri:
return self.http_status(400, -1, 'URI is required')
try:
contents = await self.ap.mcp_service.read_mcp_server_resource(server_name, uri)
return self.success(data={'contents': contents})
except Exception as e:
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
+8
View File
@@ -136,6 +136,14 @@ class MCPService:
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
"""测试 MCP 服务器连接并返回任务 ID"""
+240 -4
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import base64
import enum
import json
import typing
from contextlib import AsyncExitStack
import traceback
@@ -10,10 +12,11 @@ import asyncio
import httpx
import uuid as uuid_module
from mcp import ClientSession, StdioServerParameters
from mcp import ClientSession, StdioServerParameters, types as mcp_types
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from pydantic import AnyUrl
from .. import loader
from ....core import app
@@ -22,6 +25,43 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase # noqa: F401
# Synthesized LLM tools for MCP resources (not from server tools/list).
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
# Prefixed with langbot_ to avoid clashing with MCP server tool names.
MCP_TOOL_LIST_RESOURCES = 'langbot_mcp_list_resources'
MCP_TOOL_READ_RESOURCE = 'langbot_mcp_read_resource'
MCP_LIST_RESOURCES_SCHEMA: dict[str, typing.Any] = {
'type': 'object',
'properties': {
'server_name': {
'type': 'string',
'description': 'MCP server name as configured in LangBot (see admin / pipeline bindings).',
}
},
'required': ['server_name'],
}
MCP_READ_RESOURCE_SCHEMA: dict[str, typing.Any] = {
'type': 'object',
'properties': {
'server_name': {
'type': 'string',
'description': 'MCP server name as configured in LangBot.',
},
'uri': {
'type': 'string',
'description': 'Resource URI from langbot_mcp_list_resources output, or from MCP documentation.',
},
},
'required': ['server_name', 'uri'],
}
async def _mcp_resource_tool_placeholder(**kwargs: typing.Any) -> list[provider_message.ContentElement]:
"""LLMTool requires a func; real execution goes through MCPLoader.invoke_tool."""
raise RuntimeError('MCP resource tool execution must be routed through MCPLoader.invoke_tool')
class MCPSessionStatus(enum.Enum):
CONNECTING = 'connecting'
@@ -46,6 +86,8 @@ class RuntimeMCPSession:
functions: list[resource_tool.LLMTool] = []
resources: list[dict] = []
enable: bool
# connected: bool
@@ -82,6 +124,7 @@ class RuntimeMCPSession:
self.exit_stack = AsyncExitStack()
self.functions = []
self.resources = []
self.status = MCPSessionStatus.CONNECTING
@@ -253,6 +296,7 @@ class RuntimeMCPSession:
await self.exit_stack.aclose()
self.exit_stack = AsyncExitStack()
self.functions.clear()
self.resources.clear()
self.session = None
except Exception as e:
self.ap.logger.error(f'Error cleaning up MCP session {self.server_name}: {e}\n{traceback.format_exc()}')
@@ -348,6 +392,7 @@ class RuntimeMCPSession:
return
self.functions.clear()
self.resources.clear()
tools = await self.session.list_tools()
@@ -374,8 +419,11 @@ class RuntimeMCPSession:
elif content.type == 'image':
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
elif content.type == 'resource':
# TODO: Handle resource content
pass
if isinstance(content.resource, mcp_types.TextResourceContents):
result_contents.append(provider_message.ContentElement.from_text(content.resource.text))
elif isinstance(content.resource, mcp_types.BlobResourceContents):
decoded = base64.b64decode(content.resource.blob)
result_contents.append(provider_message.ContentElement.from_text(decoded.decode('utf-8', errors='replace')))
return result_contents
@@ -391,9 +439,49 @@ class RuntimeMCPSession:
)
)
try:
resources_result = await self.session.list_resources()
for resource in resources_result.resources:
self.resources.append({
'uri': str(resource.uri),
'name': resource.name,
'description': resource.description or '',
'mime_type': resource.mimeType or '',
})
self.ap.logger.debug(f'Refresh MCP resources: {len(self.resources)} resources found')
except Exception as e:
self.ap.logger.debug(f'MCP server {self.server_name} does not support resources or failed to list: {e}')
def get_tools(self) -> list[resource_tool.LLMTool]:
return self.functions
def get_resources(self) -> list[dict]:
return self.resources
async def read_resource(self, uri: str) -> list[dict]:
"""Read a resource by URI and return its contents."""
if not self.session:
raise Exception('MCP session is not connected')
result = await self.session.read_resource(AnyUrl(uri))
contents = []
for content in result.contents:
if isinstance(content, mcp_types.TextResourceContents):
contents.append({
'uri': str(content.uri),
'mime_type': content.mimeType or '',
'type': 'text',
'text': content.text,
})
elif isinstance(content, mcp_types.BlobResourceContents):
contents.append({
'uri': str(content.uri),
'mime_type': content.mimeType or '',
'type': 'blob',
'blob': content.blob,
})
return contents
def get_runtime_info_dict(self) -> dict:
info = {
'status': self.status.value,
@@ -409,6 +497,8 @@ class RuntimeMCPSession:
}
for tool in self.get_tools()
],
'resource_count': len(self.get_resources()),
'resources': self.get_resources(),
}
if self._uses_box_stdio():
info['box_session_id'] = self._build_box_session_id()
@@ -575,8 +665,130 @@ class MCPLoader(loader.ToolLoader):
return session
@staticmethod
def _get_bound_mcp_from_query(query: pipeline_query.Query) -> list[str] | None:
v = getattr(query, 'variables', None) or {}
return v.get('_pipeline_bound_mcp_servers', None)
def _eligible_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]:
out: list[RuntimeMCPSession] = []
for session in self.sessions.values():
if not session.enable:
continue
if session.status != MCPSessionStatus.CONNECTED:
continue
if session.session is None:
continue
if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers:
continue
out.append(session)
return out
@staticmethod
def _mcp_synthetic_resource_tools() -> list[resource_tool.LLMTool]:
return [
resource_tool.LLMTool(
name=MCP_TOOL_LIST_RESOURCES,
human_desc='List MCP resource URIs for a server (MCP resources/list).',
description=(
'Lists static resources (URI, name, description, mime type) exposed by the MCP server. '
'Call langbot_mcp_read_resource with a URI to fetch content. '
'Use the server name from LangBot pipeline MCP bindings or admin configuration.'
),
parameters=MCP_LIST_RESOURCES_SCHEMA,
func=_mcp_resource_tool_placeholder,
),
resource_tool.LLMTool(
name=MCP_TOOL_READ_RESOURCE,
human_desc='Read a single MCP resource by URI (MCP resources/read).',
description=(
'Fetches the body of a resource. Discover URIs via langbot_mcp_list_resources or MCP docs. '
'The businessId, env, and other parameters in downstream tools must match the selected environment.'
),
parameters=MCP_READ_RESOURCE_SCHEMA,
func=_mcp_resource_tool_placeholder,
),
]
async def _invoke_mcp_list_resources(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
server_name = parameters.get('server_name') if parameters else None
if not server_name or not isinstance(server_name, str):
return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
allowed = {s.server_name for s in self._eligible_sessions_for_bound(bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
f'Error: MCP server {server_name!r} is not available for this query. '
f'Allowed server names: {sorted(allowed)}. '
'Check pipeline MCP server bindings and that the server is connected.'
)
]
session = self.get_session(server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
data = session.get_resources()
body = {
'server_name': server_name,
'resource_count': len(data),
'resources': data,
}
return [provider_message.ContentElement.from_text(json.dumps(body, ensure_ascii=False, indent=2))]
async def _invoke_mcp_read_resource(self, parameters: dict, query: pipeline_query.Query) -> typing.Any:
server_name = parameters.get('server_name') if parameters else None
uri = parameters.get('uri') if parameters else None
if not server_name or not isinstance(server_name, str):
return [provider_message.ContentElement.from_text('Error: "server_name" (string) is required.')]
if not uri or not isinstance(uri, str):
return [provider_message.ContentElement.from_text('Error: "uri" (string) is required.')]
bound = self._get_bound_mcp_from_query(query)
allowed = {s.server_name for s in self._eligible_sessions_for_bound(bound)}
if server_name not in allowed:
return [
provider_message.ContentElement.from_text(
f'Error: MCP server {server_name!r} is not available for this query. '
f'Allowed server names: {sorted(allowed)}.'
)
]
session = self.get_session(server_name)
if session is None or session.status != MCPSessionStatus.CONNECTED:
return [provider_message.ContentElement.from_text(f'Error: MCP server not connected: {server_name!r}')]
try:
parts = await session.read_resource(uri)
except Exception as e:
self.ap.logger.error(f'read_resource {uri!r} on {server_name}: {e}\n{traceback.format_exc()}')
return [provider_message.ContentElement.from_text(f'Error reading resource: {e!s}')]
out_chunks: list[str] = []
for item in parts:
if not isinstance(item, dict):
continue
t = item.get('type', '')
if t == 'text' and 'text' in item:
out_chunks.append(typing.cast(str, item['text']))
elif t == 'blob' and 'blob' in item:
try:
raw = base64.b64decode(typing.cast(str, item['blob']))
out_chunks.append(raw.decode('utf-8', errors='replace'))
except Exception as be:
out_chunks.append(f'[Binary decode error: {be}]')
if not out_chunks:
return [
provider_message.ContentElement.from_text(
json.dumps({'uri': uri, 'contents': parts}, ensure_ascii=False, indent=2)
)
]
return [provider_message.ContentElement.from_text('\n\n'.join(out_chunks))]
async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]:
all_functions = []
all_functions: list[resource_tool.LLMTool] = []
for session in self.sessions.values():
# If bound_mcp_servers is specified, only include tools from those servers
@@ -587,12 +799,17 @@ class MCPLoader(loader.ToolLoader):
# If no bound servers specified, include all tools
all_functions.extend(session.get_tools())
if self._eligible_sessions_for_bound(bound_mcp_servers):
all_functions.extend(self._mcp_synthetic_resource_tools())
self._last_listed_functions = all_functions
return all_functions
async def has_tool(self, name: str) -> bool:
"""检查工具是否存在"""
if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE):
return bool(self._eligible_sessions_for_bound(None))
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
@@ -608,6 +825,11 @@ class MCPLoader(loader.ToolLoader):
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
"""执行工具调用"""
if name == MCP_TOOL_LIST_RESOURCES:
return await self._invoke_mcp_list_resources(parameters, query)
if name == MCP_TOOL_READ_RESOURCE:
return await self._invoke_mcp_read_resource(parameters, query)
for session in self.sessions.values():
for function in session.get_tools():
if function.name == name:
@@ -622,6 +844,20 @@ class MCPLoader(loader.ToolLoader):
raise ValueError(f'Tool not found: {name}')
async def get_resources(self, server_name: str) -> list[dict]:
"""Get resources from a specific MCP server."""
session = self.get_session(server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return session.get_resources()
async def read_resource(self, server_name: str, uri: str) -> list[dict]:
"""Read a resource from a specific MCP server."""
session = self.get_session(server_name)
if session is None:
raise ValueError(f'MCP server not found: {server_name}')
return await session.read_resource(uri)
async def remove_mcp_server(self, server_name: str):
"""移除 MCP 服务器"""
if server_name not in self.sessions:
@@ -45,6 +45,8 @@ import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPResource,
MCPResourceContent,
MCPServer,
MCPSessionStatus,
MCPServerExtraArgsRemote,
@@ -244,13 +246,105 @@ function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
);
}
// Resources list component
function ResourcesList({
resources,
serverName,
t,
}: {
resources: MCPResource[];
serverName: string;
t: (key: string) => string;
}) {
const [expandedUri, setExpandedUri] = React.useState<string | null>(null);
const [resourceContent, setResourceContent] =
React.useState<MCPResourceContent | null>(null);
const [loadingContent, setLoadingContent] = React.useState(false);
const handleToggleResource = async (uri: string) => {
if (expandedUri === uri) {
setExpandedUri(null);
setResourceContent(null);
return;
}
setExpandedUri(uri);
setResourceContent(null);
setLoadingContent(true);
try {
const resp = await httpClient.readMCPServerResource(serverName, uri);
if (resp.contents && resp.contents.length > 0) {
setResourceContent(resp.contents[0]);
}
} catch {
setResourceContent(null);
} finally {
setLoadingContent(false);
}
};
return (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{resources.map((resource, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader
className="cursor-pointer"
onClick={() => handleToggleResource(resource.uri)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">{resource.name}</CardTitle>
{resource.mime_type && (
<span className="text-xs text-muted-foreground font-mono">
{resource.mime_type}
</span>
)}
</div>
{resource.description && (
<CardDescription className="text-xs">
{resource.description}
</CardDescription>
)}
<div className="text-xs text-muted-foreground font-mono break-all">
{resource.uri}
</div>
</CardHeader>
{expandedUri === resource.uri && (
<CardContent>
{loadingContent ? (
<div className="text-xs text-muted-foreground">
{t('mcp.loading')}
</div>
) : resourceContent?.type === 'text' && resourceContent.text ? (
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-[200px] whitespace-pre-wrap break-all">
{resourceContent.text}
</pre>
) : resourceContent?.type === 'blob' ? (
<div className="text-xs text-muted-foreground">
{t('mcp.resourceBinaryContent')}
</div>
) : (
<div className="text-xs text-muted-foreground">
{t('mcp.resourceReadFailed')}
</div>
)}
</CardContent>
)}
</Card>
))}
</div>
);
}
function RuntimePanel({
mcpTesting,
runtimeInfo,
serverName,
t,
}: {
mcpTesting: boolean;
runtimeInfo: MCPServerRuntimeInfo | null;
serverName: string;
t: TFunction;
}) {
// Show tools whenever we have runtime info — either an edit-mode server or a
@@ -267,6 +361,7 @@ function RuntimePanel({
const isConnected =
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
const tools = runtimeInfo.tools || [];
const resources = runtimeInfo.resources || [];
return (
<section className="space-y-4">
@@ -278,7 +373,16 @@ function RuntimePanel({
{isConnected && tools.length > 0 && <ToolsList tools={tools} t={t} />}
{isConnected && tools.length === 0 && (
{isConnected && resources.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('mcp.resourceCount', { count: resources.length })}
</div>
<ResourcesList resources={resources} serverName={serverName} t={t} />
</div>
)}
{isConnected && tools.length === 0 && resources.length === 0 && (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
{t('mcp.noToolsFound')}
</div>
@@ -732,6 +836,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
error_message: errorMsg,
tool_count: 0,
tools: [],
resource_count: 0,
resources: [],
});
} else {
if (isEditMode) {
@@ -1026,7 +1132,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
);
const runtimePanel = (
<RuntimePanel mcpTesting={mcpTesting} runtimeInfo={runtimeInfo} t={t} />
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
t={t}
/>
);
// In edit mode the right side shows a tablist switching between the live
+25
View File
@@ -561,6 +561,8 @@ export interface MCPServerRuntimeInfo {
* server runs inside Box. Absent when Box is unavailable. */
box_session_id?: string;
box_enabled?: boolean;
resource_count: number;
resources: MCPResource[];
}
export type MCPServer =
@@ -615,6 +617,29 @@ export interface MCPTool {
parameters?: object;
}
export interface MCPResource {
uri: string;
name: string;
description: string;
mime_type: string;
}
export interface MCPResourceContent {
uri: string;
mime_type: string;
type: 'text' | 'blob';
text?: string;
blob?: string;
}
export interface ApiRespMCPResources {
resources: MCPResource[];
}
export interface ApiRespMCPResourceContents {
contents: MCPResourceContent[];
}
export interface PluginTool {
name: string;
description: string;
+17
View File
@@ -40,6 +40,8 @@ import {
BoxSessionInfo,
ApiRespMCPServers,
ApiRespMCPServer,
ApiRespMCPResources,
ApiRespMCPResourceContents,
MCPServer,
ApiRespModelProviders,
ApiRespModelProvider,
@@ -905,6 +907,21 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/mcp/servers', { source });
}
public getMCPServerResources(
serverName: string,
): Promise<ApiRespMCPResources> {
return this.get(`/api/v1/mcp/servers/${serverName}/resources`);
}
public readMCPServerResource(
serverName: string,
uri: string,
): Promise<ApiRespMCPResourceContents> {
return this.post(`/api/v1/mcp/servers/${serverName}/resources/read`, {
uri,
});
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');
+3
View File
@@ -824,6 +824,9 @@ const enUS = {
toolCount: 'Tools: {{count}}',
parameterCount: 'Parameters: {{count}}',
noParameters: 'No parameters',
resourceCount: 'Resources: {{count}}',
resourceBinaryContent: 'Binary content (cannot be displayed)',
resourceReadFailed: 'Failed to read resource content',
statusConnected: 'Connected',
statusDisconnected: 'Disconnected',
statusError: 'Connection Error',
+3
View File
@@ -838,6 +838,9 @@ const esES = {
toolCount: 'Herramientas: {{count}}',
parameterCount: 'Parámetros: {{count}}',
noParameters: 'Sin parámetros',
resourceCount: 'Recursos: {{count}}',
resourceBinaryContent: 'Contenido binario (no se puede mostrar)',
resourceReadFailed: 'Error al leer el contenido del recurso',
statusConnected: 'Conectado',
statusDisconnected: 'Desconectado',
statusError: 'Error de conexión',
+3
View File
@@ -830,6 +830,9 @@ const jaJP = {
toolCount: 'ツール:{{count}}',
parameterCount: 'パラメータ:{{count}}',
noParameters: 'パラメータなし',
resourceCount: 'リソース:{{count}}',
resourceBinaryContent: 'バイナリコンテンツ(表示できません)',
resourceReadFailed: 'リソースの読み込みに失敗しました',
statusConnected: '接続済み',
statusDisconnected: '未接続',
statusError: '接続エラー',
+3
View File
@@ -835,6 +835,9 @@ const ruRU = {
toolCount: 'Инструменты: {{count}}',
parameterCount: 'Параметры: {{count}}',
noParameters: 'Нет параметров',
resourceCount: 'Ресурсы: {{count}}',
resourceBinaryContent: 'Двоичное содержимое (невозможно отобразить)',
resourceReadFailed: 'Не удалось прочитать содержимое ресурса',
statusConnected: 'Подключён',
statusDisconnected: 'Отключён',
statusError: 'Ошибка подключения',
+3
View File
@@ -813,6 +813,9 @@ const thTH = {
toolCount: 'เครื่องมือ: {{count}}',
parameterCount: 'พารามิเตอร์: {{count}}',
noParameters: 'ไม่มีพารามิเตอร์',
resourceCount: 'ทรัพยากร: {{count}}',
resourceBinaryContent: 'เนื้อหาไบนารี (ไม่สามารถแสดงได้)',
resourceReadFailed: 'ไม่สามารถอ่านเนื้อหาทรัพยากรได้',
statusConnected: 'เชื่อมต่อแล้ว',
statusDisconnected: 'ไม่ได้เชื่อมต่อ',
statusError: 'ข้อผิดพลาดการเชื่อมต่อ',
+3
View File
@@ -828,6 +828,9 @@ const viVN = {
toolCount: 'Công cụ: {{count}}',
parameterCount: 'Tham số: {{count}}',
noParameters: 'Không có tham số',
resourceCount: 'Tài nguyên: {{count}}',
resourceBinaryContent: 'Nội dung nhị phân (không thể hiển thị)',
resourceReadFailed: 'Không thể đọc nội dung tài nguyên',
statusConnected: 'Đã kết nối',
statusDisconnected: 'Đã ngắt kết nối',
statusError: 'Lỗi kết nối',
+3
View File
@@ -791,6 +791,9 @@ const zhHans = {
toolCount: '工具:{{count}}',
parameterCount: '参数:{{count}}',
noParameters: '无参数',
resourceCount: '资源:{{count}}',
resourceBinaryContent: '二进制内容(无法显示)',
resourceReadFailed: '读取资源内容失败',
statusConnected: '已连接',
statusDisconnected: '未连接',
statusError: '连接错误',
+3
View File
@@ -790,6 +790,9 @@ const zhHant = {
toolCount: '工具:{{count}}',
parameterCount: '參數:{{count}}',
noParameters: '無參數',
resourceCount: '資源:{{count}}',
resourceBinaryContent: '二進位內容(無法顯示)',
resourceReadFailed: '讀取資源內容失敗',
statusConnected: '已連線',
statusDisconnected: '未連線',
statusError: '連接錯誤',