mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 01:16:07 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0347515ace | |||
| 5ab237138e | |||
| 097219556c |
@@ -0,0 +1,196 @@
|
||||
# MCP Resources PR #2215 Review
|
||||
|
||||
> 更新日期: 2026-06-29
|
||||
> 分支: `mcp_resources`
|
||||
> PR: langbot-app/LangBot#2215
|
||||
> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向
|
||||
|
||||
## 结论
|
||||
|
||||
PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。
|
||||
|
||||
但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools:
|
||||
|
||||
- `langbot_mcp_list_resources`
|
||||
- `langbot_mcp_read_resource`
|
||||
|
||||
这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。
|
||||
|
||||
建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。
|
||||
|
||||
## 当前实现判断
|
||||
|
||||
当前 AgentRunner 集成路径如下:
|
||||
|
||||
```text
|
||||
Pipeline 绑定 MCP server
|
||||
-> query.variables['_pipeline_bound_mcp_servers']
|
||||
-> Preproc 为 local-agent 加载工具
|
||||
-> ToolManager.get_all_tools()
|
||||
-> MCPLoader 注入 synthetic resource tools
|
||||
-> LocalAgentRunner 将工具 schema 传给模型
|
||||
-> 模型发起 list/read tool call
|
||||
-> ToolManager.execute_func_call()
|
||||
-> MCPLoader 调 MCP session.list_resources/read_resource
|
||||
-> tool result 回灌给模型
|
||||
```
|
||||
|
||||
这个路径的优点是:
|
||||
|
||||
- 复用现有工具调用机制,改动范围小。
|
||||
- Agent 可以按需探索资源,不需要每轮预先读取所有资源。
|
||||
- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。
|
||||
- 对已有 MCP tools 行为影响较小。
|
||||
|
||||
主要问题是:
|
||||
|
||||
- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。
|
||||
- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。
|
||||
- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。
|
||||
- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。
|
||||
- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。
|
||||
- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。
|
||||
|
||||
## 主流项目做法
|
||||
|
||||
### MCP 官方规范
|
||||
|
||||
MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。
|
||||
|
||||
这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。
|
||||
|
||||
参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
|
||||
|
||||
### VS Code Copilot
|
||||
|
||||
VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。
|
||||
|
||||
这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。
|
||||
|
||||
参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers
|
||||
|
||||
### Anthropic SDK
|
||||
|
||||
Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。
|
||||
|
||||
这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。
|
||||
|
||||
参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
|
||||
|
||||
### LangChain MCP Adapters
|
||||
|
||||
LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。
|
||||
|
||||
这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。
|
||||
|
||||
参考: https://docs.langchain.com/oss/python/langchain/mcp
|
||||
|
||||
### OpenAI Agents SDK
|
||||
|
||||
OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources`、`list_resource_templates`、`read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。
|
||||
|
||||
参考: https://openai.github.io/openai-agents-python/mcp/
|
||||
|
||||
### Cline
|
||||
|
||||
Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。
|
||||
|
||||
这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。
|
||||
|
||||
参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts
|
||||
|
||||
## 建议架构方向
|
||||
|
||||
### 1. 保留探索型工具
|
||||
|
||||
保留当前两个 synthetic tools:
|
||||
|
||||
- `langbot_mcp_list_resources`
|
||||
- `langbot_mcp_read_resource`
|
||||
|
||||
它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。
|
||||
|
||||
### 2. 增加一等 Resource Context
|
||||
|
||||
新增一个 Host 层资源上下文概念,例如:
|
||||
|
||||
```text
|
||||
PipelineResourceBinding
|
||||
ConversationResourceAttachment
|
||||
MessageResourceAttachment
|
||||
```
|
||||
|
||||
Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。
|
||||
|
||||
### 3. 打通 UI 与 Agent 上下文
|
||||
|
||||
当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作:
|
||||
|
||||
- 添加到本轮上下文
|
||||
- 固定到当前 pipeline
|
||||
- 固定到当前 bot / conversation
|
||||
- 查看资源读取历史和错误
|
||||
|
||||
这样 UI 资源管理能力才能真正影响 Agent 行为。
|
||||
|
||||
### 4. 支持 resource templates
|
||||
|
||||
MCP resource templates 允许 server 暴露参数化资源,例如:
|
||||
|
||||
```text
|
||||
repo://{owner}/{repo}/file/{path}
|
||||
log://{service}/{date}
|
||||
```
|
||||
|
||||
LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。
|
||||
|
||||
### 5. 增加资源处理策略
|
||||
|
||||
建议补齐:
|
||||
|
||||
- 文本资源 token budget 与截断策略。
|
||||
- 大文件 chunk 与摘要策略。
|
||||
- 图片/blob 的模型能力判断与 fallback。
|
||||
- MIME 类型白名单与安全限制。
|
||||
- 缓存与过期策略。
|
||||
- `resources/listChanged` 或订阅更新。
|
||||
- resource read trace,便于审计 Agent 读取了什么上下文。
|
||||
|
||||
## 推荐落地顺序
|
||||
|
||||
### Phase 1: 完成当前 PR 可用性
|
||||
|
||||
- 保留 synthetic tools。
|
||||
- 明确文档说明当前 Agent 集成是 tool-mediated。
|
||||
- 完善资源工具描述,降低模型误用概率。
|
||||
- 给 read/list 增加大小限制和更清晰的 MIME 处理。
|
||||
- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。
|
||||
|
||||
### Phase 2: 做 Host-owned context attachments
|
||||
|
||||
- 在 pipeline 或 conversation 层新增 resource attachment 配置。
|
||||
- Preproc 读取已绑定 resources,注入模型上下文。
|
||||
- UI 支持“添加到上下文 / 固定到 pipeline”。
|
||||
- 记录每轮实际注入的 resource URI 和 token 消耗。
|
||||
|
||||
### Phase 3: 做完整 MCP Resources 能力
|
||||
|
||||
- 支持 resource templates。
|
||||
- 支持资源订阅更新。
|
||||
- 支持 chunk、summary、RAG 化接入。
|
||||
- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。
|
||||
|
||||
## 最终建议
|
||||
|
||||
PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。
|
||||
|
||||
但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列:
|
||||
|
||||
```text
|
||||
Tools -> Agent 可以执行的动作
|
||||
Resources -> Host/用户/Agent 可以选择的上下文数据
|
||||
Prompts -> 可复用的任务模板
|
||||
Knowledge -> 可检索、可索引的长期知识
|
||||
```
|
||||
|
||||
这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。
|
||||
@@ -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)}')
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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,108 @@ function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 pb-6">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
type RuntimePanelContent = 'all' | 'tools' | 'resources';
|
||||
|
||||
function RuntimePanel({
|
||||
mcpTesting,
|
||||
runtimeInfo,
|
||||
serverName,
|
||||
content = 'all',
|
||||
t,
|
||||
}: {
|
||||
mcpTesting: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo | null;
|
||||
serverName: string;
|
||||
content?: RuntimePanelContent;
|
||||
t: TFunction;
|
||||
}) {
|
||||
// Show tools whenever we have runtime info — either an edit-mode server or a
|
||||
@@ -259,7 +356,9 @@ function RuntimePanel({
|
||||
if (!runtimeInfo) {
|
||||
return (
|
||||
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||
{t('mcp.noToolsFound')}
|
||||
{content === 'resources'
|
||||
? t('mcp.noResourcesFound')
|
||||
: t('mcp.noToolsFound')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +366,15 @@ function RuntimePanel({
|
||||
const isConnected =
|
||||
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
||||
const tools = runtimeInfo.tools || [];
|
||||
const resources = runtimeInfo.resources || [];
|
||||
const showTools = content === 'all' || content === 'tools';
|
||||
const showResources = content === 'all' || content === 'resources';
|
||||
const empty =
|
||||
content === 'tools'
|
||||
? tools.length === 0
|
||||
: content === 'resources'
|
||||
? resources.length === 0
|
||||
: tools.length === 0 && resources.length === 0;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -276,11 +384,26 @@ function RuntimePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && tools.length > 0 && <ToolsList tools={tools} t={t} />}
|
||||
{isConnected && showTools && tools.length > 0 && (
|
||||
<ToolsList tools={tools} t={t} />
|
||||
)}
|
||||
|
||||
{isConnected && tools.length === 0 && (
|
||||
{isConnected && showResources && resources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{content === 'all' && (
|
||||
<div className="text-sm font-medium">
|
||||
{t('mcp.resourceCount', { count: resources.length })}
|
||||
</div>
|
||||
)}
|
||||
<ResourcesList resources={resources} serverName={serverName} t={t} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && empty && (
|
||||
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||
{t('mcp.noToolsFound')}
|
||||
{content === 'resources'
|
||||
? t('mcp.noResourcesFound')
|
||||
: t('mcp.noToolsFound')}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -732,6 +855,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
@@ -1026,20 +1151,29 @@ 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
|
||||
// Tools list and the Docs (README captured from LangBot Space at install).
|
||||
// Tools/resources lists and the Docs (README captured from LangBot Space at install).
|
||||
// Create mode has neither, so it falls back to the bare runtime placeholder.
|
||||
// The tool count lives in the tab label (only when connected); the panel
|
||||
// Counts live in the tab labels (only when connected); the panel
|
||||
// body itself no longer repeats a title/subtitle.
|
||||
const toolsConnected =
|
||||
const runtimeConnected =
|
||||
!mcpTesting && runtimeInfo?.status === MCPSessionStatus.CONNECTED;
|
||||
const toolsCount = runtimeInfo?.tools?.length ?? 0;
|
||||
const toolsTabLabel = toolsConnected
|
||||
const resourcesCount = runtimeInfo?.resources?.length ?? 0;
|
||||
const toolsTabLabel = runtimeConnected
|
||||
? `${t('mcp.tabTools')} ${toolsCount}`
|
||||
: t('mcp.tabTools');
|
||||
const resourcesTabLabel = runtimeConnected
|
||||
? `${t('mcp.tabResources')} ${resourcesCount}`
|
||||
: t('mcp.tabResources');
|
||||
|
||||
const detailPanel = isEditMode ? (
|
||||
<Tabs defaultValue="tools" className="flex h-full min-h-0 flex-col">
|
||||
@@ -1050,6 +1184,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<TabsTrigger value="tools" className="flex-none px-4">
|
||||
{toolsTabLabel}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="resources" className="flex-none px-4">
|
||||
{resourcesTabLabel}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||
<MCPReadme readme={readme} />
|
||||
@@ -1058,7 +1195,25 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
value="tools"
|
||||
className="mt-4 min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{runtimePanel}
|
||||
<RuntimePanel
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
serverName={form.getValues('name')}
|
||||
content="tools"
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="resources"
|
||||
className="mt-4 min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<RuntimePanel
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
serverName={form.getValues('name')}
|
||||
content="resources"
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -804,7 +804,9 @@ const enUS = {
|
||||
toolsFound: 'tools',
|
||||
unknownError: 'Unknown error',
|
||||
noToolsFound: 'No tools found',
|
||||
noResourcesFound: 'No resources found',
|
||||
tabTools: 'Tools',
|
||||
tabResources: 'Resources',
|
||||
tabDocs: 'Docs',
|
||||
noReadme: 'No documentation available',
|
||||
parseResultFailed: 'Failed to parse test result',
|
||||
@@ -824,6 +826,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',
|
||||
|
||||
@@ -818,7 +818,9 @@ const esES = {
|
||||
toolsFound: 'herramientas',
|
||||
unknownError: 'Error desconocido',
|
||||
noToolsFound: 'No se encontraron herramientas',
|
||||
noResourcesFound: 'No se encontraron recursos',
|
||||
tabTools: 'Herramientas',
|
||||
tabResources: 'Recursos',
|
||||
tabDocs: 'Documentación',
|
||||
noReadme: 'No hay documentación disponible',
|
||||
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
||||
@@ -838,6 +840,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',
|
||||
|
||||
@@ -810,7 +810,9 @@ const jaJP = {
|
||||
toolsFound: '個のツール',
|
||||
unknownError: '不明なエラー',
|
||||
noToolsFound: 'ツールが見つかりません',
|
||||
noResourcesFound: 'リソースが見つかりません',
|
||||
tabTools: 'ツール',
|
||||
tabResources: 'リソース',
|
||||
tabDocs: 'ドキュメント',
|
||||
noReadme: 'ドキュメントがありません',
|
||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||
@@ -830,6 +832,9 @@ const jaJP = {
|
||||
toolCount: 'ツール:{{count}}',
|
||||
parameterCount: 'パラメータ:{{count}}',
|
||||
noParameters: 'パラメータなし',
|
||||
resourceCount: 'リソース:{{count}}',
|
||||
resourceBinaryContent: 'バイナリコンテンツ(表示できません)',
|
||||
resourceReadFailed: 'リソースの読み込みに失敗しました',
|
||||
statusConnected: '接続済み',
|
||||
statusDisconnected: '未接続',
|
||||
statusError: '接続エラー',
|
||||
|
||||
@@ -815,7 +815,9 @@ const ruRU = {
|
||||
toolsFound: 'инструментов',
|
||||
unknownError: 'Неизвестная ошибка',
|
||||
noToolsFound: 'Инструменты не найдены',
|
||||
noResourcesFound: 'Ресурсы не найдены',
|
||||
tabTools: 'Инструменты',
|
||||
tabResources: 'Ресурсы',
|
||||
tabDocs: 'Документация',
|
||||
noReadme: 'Документация отсутствует',
|
||||
parseResultFailed: 'Не удалось разобрать результат теста',
|
||||
@@ -835,6 +837,9 @@ const ruRU = {
|
||||
toolCount: 'Инструменты: {{count}}',
|
||||
parameterCount: 'Параметры: {{count}}',
|
||||
noParameters: 'Нет параметров',
|
||||
resourceCount: 'Ресурсы: {{count}}',
|
||||
resourceBinaryContent: 'Двоичное содержимое (невозможно отобразить)',
|
||||
resourceReadFailed: 'Не удалось прочитать содержимое ресурса',
|
||||
statusConnected: 'Подключён',
|
||||
statusDisconnected: 'Отключён',
|
||||
statusError: 'Ошибка подключения',
|
||||
|
||||
@@ -793,7 +793,9 @@ const thTH = {
|
||||
toolsFound: 'เครื่องมือ',
|
||||
unknownError: 'ข้อผิดพลาดที่ไม่ทราบสาเหตุ',
|
||||
noToolsFound: 'ไม่พบเครื่องมือ',
|
||||
noResourcesFound: 'ไม่พบทรัพยากร',
|
||||
tabTools: 'เครื่องมือ',
|
||||
tabResources: 'ทรัพยากร',
|
||||
tabDocs: 'เอกสาร',
|
||||
noReadme: 'ไม่มีเอกสาร',
|
||||
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
||||
@@ -813,6 +815,9 @@ const thTH = {
|
||||
toolCount: 'เครื่องมือ: {{count}}',
|
||||
parameterCount: 'พารามิเตอร์: {{count}}',
|
||||
noParameters: 'ไม่มีพารามิเตอร์',
|
||||
resourceCount: 'ทรัพยากร: {{count}}',
|
||||
resourceBinaryContent: 'เนื้อหาไบนารี (ไม่สามารถแสดงได้)',
|
||||
resourceReadFailed: 'ไม่สามารถอ่านเนื้อหาทรัพยากรได้',
|
||||
statusConnected: 'เชื่อมต่อแล้ว',
|
||||
statusDisconnected: 'ไม่ได้เชื่อมต่อ',
|
||||
statusError: 'ข้อผิดพลาดการเชื่อมต่อ',
|
||||
|
||||
@@ -808,7 +808,9 @@ const viVN = {
|
||||
toolsFound: 'công cụ',
|
||||
unknownError: 'Lỗi không xác định',
|
||||
noToolsFound: 'Không tìm thấy công cụ nào',
|
||||
noResourcesFound: 'Không tìm thấy tài nguyên nào',
|
||||
tabTools: 'Công cụ',
|
||||
tabResources: 'Tài nguyên',
|
||||
tabDocs: 'Tài liệu',
|
||||
noReadme: 'Không có tài liệu',
|
||||
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
||||
@@ -828,6 +830,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',
|
||||
|
||||
@@ -771,7 +771,9 @@ const zhHans = {
|
||||
toolsFound: '个工具',
|
||||
unknownError: '未知错误',
|
||||
noToolsFound: '未找到任何工具',
|
||||
noResourcesFound: '未找到任何资源',
|
||||
tabTools: '工具',
|
||||
tabResources: '资源',
|
||||
tabDocs: '文档',
|
||||
noReadme: '暂无文档',
|
||||
parseResultFailed: '解析测试结果失败',
|
||||
@@ -791,6 +793,9 @@ const zhHans = {
|
||||
toolCount: '工具:{{count}}',
|
||||
parameterCount: '参数:{{count}}',
|
||||
noParameters: '无参数',
|
||||
resourceCount: '资源:{{count}}',
|
||||
resourceBinaryContent: '二进制内容(无法显示)',
|
||||
resourceReadFailed: '读取资源内容失败',
|
||||
statusConnected: '已连接',
|
||||
statusDisconnected: '未连接',
|
||||
statusError: '连接错误',
|
||||
|
||||
@@ -770,7 +770,9 @@ const zhHant = {
|
||||
toolsFound: '個工具',
|
||||
unknownError: '未知錯誤',
|
||||
noToolsFound: '未找到任何工具',
|
||||
noResourcesFound: '未找到任何資源',
|
||||
tabTools: '工具',
|
||||
tabResources: '資源',
|
||||
tabDocs: '文件',
|
||||
noReadme: '暫無文件',
|
||||
parseResultFailed: '解析測試結果失敗',
|
||||
@@ -790,6 +792,9 @@ const zhHant = {
|
||||
toolCount: '工具:{{count}}',
|
||||
parameterCount: '參數:{{count}}',
|
||||
noParameters: '無參數',
|
||||
resourceCount: '資源:{{count}}',
|
||||
resourceBinaryContent: '二進位內容(無法顯示)',
|
||||
resourceReadFailed: '讀取資源內容失敗',
|
||||
statusConnected: '已連線',
|
||||
statusDisconnected: '未連線',
|
||||
statusError: '連接錯誤',
|
||||
|
||||
Reference in New Issue
Block a user