From 096ec1a8cebfb65cce67f65c01e36779b5ed87b4 Mon Sep 17 00:00:00 2001 From: advancer-young Date: Tue, 30 Jun 2026 19:16:30 +0800 Subject: [PATCH] feat(mcp): support mcp resources (#2215) * feat(mcp): support mcp resources * feat(web): split MCP resources into tab * docs: add MCP resources PR review * feat(mcp): productionize resource support * feat(mcp): scope local agent tools and resources * fix(web): gate space embedding models behind login * fix(web): prevent clipped space model CTA * test: update preproc resource tool expectations * fix(web): expose skill authoring tools in selector --------- Co-authored-by: yang.xiang Co-authored-by: Hyu Co-authored-by: Junyan Qin --- docs/review/mcp-resources-pr-2215-review.md | 196 +++ .../controller/groups/pipelines/pipelines.py | 8 + .../http/controller/groups/resources/mcp.py | 48 + .../http/controller/groups/resources/tools.py | 44 +- src/langbot/pkg/api/http/service/mcp.py | 26 + src/langbot/pkg/api/http/service/pipeline.py | 10 + .../pkg/entity/persistence/pipeline.py | 9 +- src/langbot/pkg/pipeline/pipelinemgr.py | 11 + src/langbot/pkg/pipeline/preproc/preproc.py | 28 +- .../pkg/provider/runners/localagent.py | 24 + src/langbot/pkg/provider/tools/loaders/mcp.py | 901 +++++++++++++- .../pkg/provider/tools/loaders/plugin.py | 18 + src/langbot/pkg/provider/tools/toolmgr.py | 44 +- .../templates/metadata/pipeline/ai.yaml | 42 +- .../api/service/test_mcp_service.py | 50 + .../api/service/test_pipeline_service.py | 43 + tests/unit_tests/pipeline/test_pipelinemgr.py | 43 + tests/unit_tests/pipeline/test_preproc.py | 58 + .../unit_tests/provider/test_mcp_resources.py | 278 +++++ .../provider/test_tool_manager_native.py | 46 +- tests/unit_tests/test_preproc.py | 34 +- .../dynamic-form/DynamicFormComponent.tsx | 224 +++- .../dynamic-form/DynamicFormItemComponent.tsx | 293 ++++- .../dynamic-form/DynamicFormItemConfig.ts | 7 + .../dynamic-form/ToolResourceSelectors.tsx | 1074 +++++++++++++++++ .../home/mcp/components/mcp-form/MCPForm.tsx | 188 ++- .../pipeline-extensions/PipelineExtension.tsx | 40 +- .../pipeline-form/PipelineFormComponent.tsx | 35 +- web/src/app/infra/entities/api/index.ts | 61 + web/src/app/infra/entities/form/dynamic.ts | 2 + web/src/app/infra/http/BackendClient.ts | 62 +- web/src/i18n/locales/en-US.ts | 46 + web/src/i18n/locales/es-ES.ts | 45 + web/src/i18n/locales/ja-JP.ts | 45 + web/src/i18n/locales/ru-RU.ts | 45 + web/src/i18n/locales/th-TH.ts | 44 + web/src/i18n/locales/vi-VN.ts | 45 + web/src/i18n/locales/zh-Hans.ts | 45 + web/src/i18n/locales/zh-Hant.ts | 42 + 39 files changed, 4103 insertions(+), 201 deletions(-) create mode 100644 docs/review/mcp-resources-pr-2215-review.md create mode 100644 tests/unit_tests/provider/test_mcp_resources.py create mode 100644 web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx diff --git a/docs/review/mcp-resources-pr-2215-review.md b/docs/review/mcp-resources-pr-2215-review.md new file mode 100644 index 000000000..8e57fc5fa --- /dev/null +++ b/docs/review/mcp-resources-pr-2215-review.md @@ -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 管理上走得更稳。 diff --git a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py index c6b2a1b43..2e45add77 100644 --- a/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py +++ b/src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py @@ -86,6 +86,10 @@ class PipelinesRouterGroup(group.RouterGroup): 'available_plugins': plugins, 'bound_mcp_servers': extensions_prefs.get('mcp_servers', []), 'available_mcp_servers': mcp_servers, + 'bound_mcp_resources': extensions_prefs.get('mcp_resources', []), + 'mcp_resource_agent_read_enabled': extensions_prefs.get( + 'mcp_resource_agent_read_enabled', True + ), 'bound_skills': extensions_prefs.get('skills', []), 'available_skills': available_skills, } @@ -99,6 +103,8 @@ class PipelinesRouterGroup(group.RouterGroup): bound_plugins = json_data.get('bound_plugins', []) bound_mcp_servers = json_data.get('bound_mcp_servers', []) bound_skills = json_data.get('bound_skills', []) + bound_mcp_resources = json_data.get('bound_mcp_resources') + mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled') await self.ap.pipeline_service.update_pipeline_extensions( pipeline_uuid, @@ -108,6 +114,8 @@ class PipelinesRouterGroup(group.RouterGroup): enable_all_mcp_servers, bound_skills=bound_skills, enable_all_skills=enable_all_skills, + bound_mcp_resources=bound_mcp_resources, + mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled, ) return self.success() diff --git a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py index e6bc2e77d..4ee3f3e84 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/mcp.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/mcp.py @@ -2,6 +2,7 @@ from __future__ import annotations import quart import traceback +from urllib.parse import unquote from ... import group @@ -66,3 +67,50 @@ 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//resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get resources from an MCP server""" + server_name = unquote(server_name) + try: + resources = await self.ap.mcp_service.get_mcp_server_resources(server_name) + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + runtime_info = await self.ap.mcp_service.get_runtime_info(server_name) + return self.success( + data={ + 'resources': resources, + 'resource_templates': templates, + 'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}), + } + ) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resources: {str(e)}') + + @self.route('/servers//resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Get resource templates from an MCP server""" + server_name = unquote(server_name) + try: + templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name) + return self.success(data={'resource_templates': templates}) + except Exception as e: + return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}') + + @self.route('/servers//resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) + async def _(server_name: str) -> str: + """Read a resource from an MCP server""" + server_name = unquote(server_name) + data = await quart.request.json + uri = data.get('uri') + if not uri: + return self.http_status(400, -1, 'URI is required') + try: + envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope( + server_name, + uri, + max_bytes=data.get('max_bytes'), + include_blob=bool(data.get('include_blob', False)), + ) + return self.success(data=envelope) + except Exception as e: + return self.http_status(500, -1, f'Failed to read resource: {str(e)}') diff --git a/src/langbot/pkg/api/http/controller/groups/resources/tools.py b/src/langbot/pkg/api/http/controller/groups/resources/tools.py index de827e544..128a0647d 100644 --- a/src/langbot/pkg/api/http/controller/groups/resources/tools.py +++ b/src/langbot/pkg/api/http/controller/groups/resources/tools.py @@ -1,5 +1,7 @@ from __future__ import annotations +import quart + from ... import group @@ -9,25 +11,41 @@ class ToolsRouterGroup(group.RouterGroup): @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _() -> str: """获取所有可用工具列表""" - tools = await self.ap.tool_mgr.get_all_tools() + pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id') + bound_plugins: list[str] | None = None + bound_mcp_servers: list[str] | None = None - tool_list = [] - for tool in tools: - tool_list.append( - { - 'name': tool.name, - 'description': tool.description, - 'human_desc': tool.human_desc, - 'parameters': tool.parameters, - } - ) + if pipeline_uuid: + pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid) + if pipeline is None: + return self.http_status(404, -1, 'pipeline not found') - return self.success(data={'tools': tool_list}) + extensions_prefs = pipeline.get('extensions_preferences', {}) or {} + if not extensions_prefs.get('enable_all_plugins', True): + bound_plugins = [ + f'{plugin.get("author", "")}/{plugin.get("name", "")}' + for plugin in extensions_prefs.get('plugins', []) + if isinstance(plugin, dict) and plugin.get('name') + ] + if not extensions_prefs.get('enable_all_mcp_servers', True): + bound_mcp_servers = [ + server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str) + ] + + return self.success( + data={ + 'tools': await self.ap.tool_mgr.get_tool_catalog( + bound_plugins, + bound_mcp_servers, + include_skill_authoring=True, + ) + } + ) @self.route('/', methods=['GET'], auth_type=group.AuthType.USER_TOKEN) async def _(tool_name: str) -> str: """获取特定工具详情""" - tools = await self.ap.tool_mgr.get_all_tools() + tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True) for tool in tools: if tool.name == tool_name: diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index 9db699c20..22e281b6f 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -136,6 +136,32 @@ 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 get_mcp_server_resource_templates(self, server_name: str) -> list[dict]: + """Get resource templates from a specific MCP server.""" + return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name) + + async def read_mcp_server_resource_envelope( + self, + server_name: str, + uri: str, + *, + max_bytes: int | None = None, + include_blob: bool = False, + ) -> dict: + """Read a resource from a specific MCP server with metadata.""" + kwargs = {'include_blob': include_blob, 'source': 'ui_preview'} + if max_bytes is not None: + kwargs['max_bytes'] = max_bytes + return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs) + + 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""" diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index d7685fe43..2a6451c8b 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -100,6 +100,8 @@ class PipelineService: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } await self.ap.persistence_mgr.execute_async( @@ -193,6 +195,8 @@ class PipelineService: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } ), } @@ -217,6 +221,8 @@ class PipelineService: enable_all_mcp_servers: bool = True, bound_skills: list[str] = None, enable_all_skills: bool = True, + bound_mcp_resources: list[dict] = None, + mcp_resource_agent_read_enabled: bool | None = None, ) -> None: """Update the bound plugins and MCP servers for a pipeline""" # Get current pipeline @@ -236,10 +242,14 @@ class PipelineService: extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers extensions_preferences['enable_all_skills'] = enable_all_skills extensions_preferences['plugins'] = bound_plugins + if mcp_resource_agent_read_enabled is not None: + extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled if bound_mcp_servers is not None: extensions_preferences['mcp_servers'] = bound_mcp_servers if bound_skills is not None: extensions_preferences['skills'] = bound_skills + if bound_mcp_resources is not None: + extensions_preferences['mcp_resources'] = bound_mcp_resources await self.ap.persistence_mgr.execute_async( sqlalchemy.update(persistence_pipeline.LegacyPipeline) diff --git a/src/langbot/pkg/entity/persistence/pipeline.py b/src/langbot/pkg/entity/persistence/pipeline.py index b3a4b7fe0..d74cf78ee 100644 --- a/src/langbot/pkg/entity/persistence/pipeline.py +++ b/src/langbot/pkg/entity/persistence/pipeline.py @@ -26,7 +26,14 @@ class LegacyPipeline(Base): extensions_preferences = sqlalchemy.Column( sqlalchemy.JSON, nullable=False, - default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []}, + default={ + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, + }, ) diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index cdd44df89..adf44b524 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -96,6 +96,15 @@ class RuntimePipeline: extensions_prefs = pipeline_entity.extensions_preferences or {} self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True) self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True) + local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {}) + self.mcp_resource_attachments = local_agent_config.get( + 'mcp-resources', + extensions_prefs.get('mcp_resources', []), + ) + self.mcp_resource_agent_read_enabled = local_agent_config.get( + 'mcp-resource-agent-read-enabled', + extensions_prefs.get('mcp_resource_agent_read_enabled', True), + ) if self.enable_all_plugins: # None indicates to use all available plugins @@ -116,6 +125,8 @@ class RuntimePipeline: # Store bound plugins and MCP servers in query for filtering query.variables['_pipeline_bound_plugins'] = self.bound_plugins query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers + query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled # Record query start for monitoring try: diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index 84e9070c8..b14d0a827 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -25,6 +25,21 @@ class PreProcessor(stage.PipelineStage): - use_funcs """ + @staticmethod + def _filter_selected_tools( + tools: list, + local_agent_config: dict, + ) -> list: + if local_agent_config.get('enable-all-tools', True) is not False: + return tools + + selected_tools = local_agent_config.get('tools', []) + if not isinstance(selected_tools, list): + return [] + + selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)} + return [tool for tool in tools if tool.name in selected_tool_names] + async def process( self, query: pipeline_query.Query, @@ -32,6 +47,7 @@ class PreProcessor(stage.PipelineStage): ) -> entities.StageProcessResult: """Process""" selected_runner = query.pipeline_config['ai']['runner']['runner'] + local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {}) include_skill_authoring = ( selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None ) @@ -43,7 +59,7 @@ class PreProcessor(stage.PipelineStage): if selected_runner == 'local-agent': # Read model config — new format is { primary: str, fallbacks: [str] }, # but handle legacy plain string for backward compatibility - model_config = query.pipeline_config['ai']['local-agent'].get('model', {}) + model_config = local_agent_config.get('model', {}) if isinstance(model_config, str): # Legacy format: plain UUID string primary_uuid = model_config @@ -113,11 +129,14 @@ class PreProcessor(stage.PipelineStage): # Get bound plugins and MCP servers for filtering tools bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools( + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( bound_plugins, bound_mcp_servers, include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) self.ap.logger.debug(f'Bound plugins: {bound_plugins}') self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}') @@ -128,11 +147,14 @@ class PreProcessor(stage.PipelineStage): if not query.use_funcs and query.variables.get('_fallback_model_uuids'): bound_plugins = query.variables.get('_pipeline_bound_plugins', None) bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None) - query.use_funcs = await self.ap.tool_mgr.get_all_tools( + include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True) + all_tools = await self.ap.tool_mgr.get_all_tools( bound_plugins, bound_mcp_servers, include_skill_authoring=include_skill_authoring, + include_mcp_resource_tools=include_mcp_resource_tools, ) + query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config) sender_name = '' diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index 482d03493..3417c6671 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -417,6 +417,30 @@ class LocalAgentRunner(runner.RequestRunner): ce.text = final_user_message_text break + mcp_loader = getattr(getattr(self.ap, 'tool_mgr', None), 'mcp_tool_loader', None) + if mcp_loader is not None: + resource_context = await mcp_loader.build_resource_context_for_query(query) + if resource_context: + resource_addition = ( + '\n\nMCP resource context selected by LangBot host:\n' + f'{resource_context}\n\n' + 'Use this context as read-only reference material. If it conflicts with the user message, ' + 'ask for clarification before taking external actions.' + ) + if isinstance(user_message.content, str): + user_message.content += resource_addition + elif isinstance(user_message.content, list): + appended = False + for ce in user_message.content: + if ce.type == 'text': + ce.text = (ce.text or '') + resource_addition + appended = True + break + if not appended: + user_message.content.append( + provider_message.ContentElement.from_text(resource_addition.strip()) + ) + req_messages = self._build_request_messages(query, user_message) try: diff --git a/src/langbot/pkg/provider/tools/loaders/mcp.py b/src/langbot/pkg/provider/tools/loaders/mcp.py index 2cc83b1c7..e059a756d 100644 --- a/src/langbot/pkg/provider/tools/loaders/mcp.py +++ b/src/langbot/pkg/provider/tools/loaders/mcp.py @@ -1,6 +1,10 @@ from __future__ import annotations +import base64 import enum +import json +import re +import time import typing from contextlib import AsyncExitStack import traceback @@ -10,10 +14,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 +27,157 @@ 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_RESOURCE_DISCOVERY_MAX_PAGES = 20 +MCP_RESOURCE_CACHE_TTL_SECONDS = 30 +MCP_RESOURCE_PREVIEW_MAX_BYTES = 64 * 1024 +MCP_RESOURCE_AGENT_READ_MAX_BYTES = 64 * 1024 +MCP_RESOURCE_AGENT_READ_MAX_TOKENS = 12000 +MCP_RESOURCE_CONTEXT_MAX_TOKENS = 8000 +MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024 +MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads' +MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links' +MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context' + +TEXT_LIKE_MIME_TYPES = { + 'application/json', + 'application/ld+json', + 'application/xml', + 'application/yaml', + 'application/x-yaml', + 'application/toml', + 'application/javascript', + 'application/typescript', + 'application/sql', + 'application/graphql', +} + +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 a listed resource template.', + }, + }, + 'required': ['server_name', 'uri'], +} + + +def _mcp_model_dump(obj: typing.Any) -> typing.Any: + if obj is None: + return None + if hasattr(obj, 'model_dump'): + return obj.model_dump(mode='json', by_alias=True, exclude_none=True) + if isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, list): + return [_mcp_model_dump(item) for item in obj] + if isinstance(obj, dict): + return {str(k): _mcp_model_dump(v) for k, v in obj.items()} + return str(obj) + + +def _truncate_text(text: str, max_bytes: int, max_tokens: int | None = None) -> tuple[str, bool, int]: + raw = text.encode('utf-8') + original_bytes = len(raw) + truncated = False + + if max_bytes > 0 and len(raw) > max_bytes: + raw = raw[:max_bytes] + text = raw.decode('utf-8', errors='ignore') + truncated = True + + if max_tokens is not None and max_tokens > 0: + max_chars = max_tokens * 4 + if len(text) > max_chars: + text = text[:max_chars] + truncated = True + + return text, truncated, original_bytes + + +def _blob_size(blob: str) -> int: + try: + return len(base64.b64decode(blob, validate=False)) + except Exception: + return len(blob.encode('utf-8', errors='ignore')) + + +def _resource_to_dict(resource: mcp_types.Resource | mcp_types.ResourceLink) -> dict: + return { + 'uri': str(resource.uri), + 'name': resource.name, + 'title': resource.title or '', + 'description': resource.description or '', + 'mime_type': resource.mimeType or '', + 'size': resource.size, + 'icons': _mcp_model_dump(resource.icons) or [], + 'annotations': _mcp_model_dump(resource.annotations) or {}, + '_meta': _mcp_model_dump(getattr(resource, 'meta', None)) or {}, + } + + +def _resource_template_to_dict(resource_template: mcp_types.ResourceTemplate) -> dict: + return { + 'uri_template': resource_template.uriTemplate, + 'name': resource_template.name, + 'title': resource_template.title or '', + 'description': resource_template.description or '', + 'mime_type': resource_template.mimeType or '', + 'icons': _mcp_model_dump(resource_template.icons) or [], + 'annotations': _mcp_model_dump(resource_template.annotations) or {}, + '_meta': _mcp_model_dump(getattr(resource_template, 'meta', None)) or {}, + } + + +def _is_text_like_mime(mime_type: str) -> bool: + if not mime_type: + return False + normalized = mime_type.split(';', 1)[0].strip().lower() + return normalized.startswith('text/') or normalized in TEXT_LIKE_MIME_TYPES or normalized.endswith('+json') + + +def _uri_matches_template(uri: str, uri_template: str) -> bool: + if uri_template == uri: + return True + if not uri_template or '{' not in uri_template: + return False + + pattern_parts: list[str] = [] + pos = 0 + for match in re.finditer(r'\{[^{}]+\}', uri_template): + pattern_parts.append(re.escape(uri_template[pos : match.start()])) + pattern_parts.append(r'[^\s]+') + pos = match.end() + pattern_parts.append(re.escape(uri_template[pos:])) + return re.fullmatch(''.join(pattern_parts), uri) is not None + + +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 +202,12 @@ class RuntimeMCPSession: functions: list[resource_tool.LLMTool] = [] + resources: list[dict] = [] + + resource_templates: list[dict] = [] + + resource_capabilities: dict = {} + enable: bool # connected: bool @@ -82,6 +244,10 @@ class RuntimeMCPSession: self.exit_stack = AsyncExitStack() self.functions = [] + self.resources = [] + self.resource_templates = [] + self.resource_capabilities = {} + self._resource_cache: dict[tuple[str, int, int | None, bool], dict] = {} self.status = MCPSessionStatus.CONNECTING @@ -253,6 +419,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 +515,15 @@ class RuntimeMCPSession: return self.functions.clear() + self.resources.clear() + self.resource_templates.clear() + self._resource_cache.clear() + + try: + capabilities = self.session.get_server_capabilities() + self.resource_capabilities = _mcp_model_dump(getattr(capabilities, 'resources', None)) or {} + except Exception: + self.resource_capabilities = {} tools = await self.session.list_tools() @@ -356,28 +532,7 @@ class RuntimeMCPSession: for tool in tools.tools: async def func(*, _tool=tool, **kwargs): - if not self.session: - raise Exception('MCP session is not connected') - - result = await self.session.call_tool(_tool.name, kwargs) - if result.isError: - error_texts = [] - for content in result.content: - if content.type == 'text': - error_texts.append(content.text) - raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool') - - result_contents: list[provider_message.ContentElement] = [] - for content in result.content: - if content.type == 'text': - result_contents.append(provider_message.ContentElement.from_text(content.text)) - 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 - - return result_contents + return await self.invoke_mcp_tool(_tool.name, kwargs) func.__name__ = tool.name @@ -391,9 +546,335 @@ class RuntimeMCPSession: ) ) + await self._refresh_resources() + + async def _refresh_resources(self): + if not self.session: + return + + try: + cursor: str | None = None + for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES): + resources_result = await self.session.list_resources(cursor) + for resource in resources_result.resources: + self.resources.append(_resource_to_dict(resource)) + cursor = getattr(resources_result, 'nextCursor', None) + if not cursor: + break + 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}') + + try: + cursor = None + for _ in range(MCP_RESOURCE_DISCOVERY_MAX_PAGES): + templates_result = await self.session.list_resource_templates(cursor) + for template in templates_result.resourceTemplates: + self.resource_templates.append(_resource_template_to_dict(template)) + cursor = getattr(templates_result, 'nextCursor', None) + if not cursor: + break + self.ap.logger.debug(f'Refresh MCP resource templates: {len(self.resource_templates)} templates found') + except Exception as e: + self.ap.logger.debug( + f'MCP server {self.server_name} does not support resource templates or failed to list: {e}' + ) + + def _record_query_resource_link( + self, + query: pipeline_query.Query | None, + resource_link: dict, + source_tool: str, + ) -> None: + if query is None: + return + try: + link = { + **resource_link, + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'source_tool': source_tool, + } + query.variables.setdefault(MCP_RESOURCE_LINKS_QUERY_KEY, []).append(link) + except Exception: + pass + + def _content_to_provider_elements( + self, + content: typing.Any, + *, + query: pipeline_query.Query | None = None, + source_tool: str = '', + ) -> list[provider_message.ContentElement]: + content_type = getattr(content, 'type', '') + if content_type == 'text': + return [provider_message.ContentElement.from_text(content.text)] + + if content_type == 'image': + image_data = getattr(content, 'data', None) or getattr(content, 'image_base64', None) + if image_data: + return [provider_message.ContentElement.from_image_base64(image_data)] + return [] + + if content_type == 'audio': + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'audio', + 'mime_type': getattr(content, 'mimeType', ''), + 'message': 'Audio content returned by MCP tool is available to the host but not inlined.', + }, + ensure_ascii=False, + ) + ) + ] + + if content_type == 'resource_link': + resource_link = _resource_to_dict(content) + self._record_query_resource_link(query, resource_link, source_tool) + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'resource_link', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'resource': resource_link, + 'message': 'Resource link captured. Read it only if the task needs this additional context.', + }, + ensure_ascii=False, + indent=2, + ) + ) + ] + + if content_type == 'resource': + resource = getattr(content, 'resource', None) + if isinstance(resource, mcp_types.TextResourceContents): + text, truncated, original_bytes = _truncate_text( + resource.text, + MCP_RESOURCE_AGENT_READ_MAX_BYTES, + MCP_RESOURCE_AGENT_READ_MAX_TOKENS, + ) + header = { + 'type': 'embedded_resource', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': str(resource.uri), + 'mime_type': resource.mimeType or '', + 'bytes': original_bytes, + 'truncated': truncated, + } + return [provider_message.ContentElement.from_text(f'{json.dumps(header, ensure_ascii=False)}\n{text}')] + if isinstance(resource, mcp_types.BlobResourceContents): + return [ + provider_message.ContentElement.from_text( + json.dumps( + { + 'type': 'embedded_resource', + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': str(resource.uri), + 'mime_type': resource.mimeType or '', + 'bytes': _blob_size(resource.blob), + 'binary_omitted': True, + }, + ensure_ascii=False, + ) + ) + ] + + return [] + + async def invoke_mcp_tool( + self, + tool_name: str, + arguments: dict, + query: pipeline_query.Query | None = None, + ) -> list[provider_message.ContentElement]: + if not self.session: + raise Exception('MCP session is not connected') + + result = await self.session.call_tool(tool_name, arguments) + if result.isError: + error_texts = [] + for content in result.content: + if getattr(content, 'type', '') == 'text': + error_texts.append(content.text) + raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool') + + result_contents: list[provider_message.ContentElement] = [] + for content in result.content: + result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name)) + return result_contents + def get_tools(self) -> list[resource_tool.LLMTool]: return self.functions + def get_resources(self) -> list[dict]: + return self.resources + + def get_resource_templates(self) -> list[dict]: + return self.resource_templates + + def has_resource_support(self) -> bool: + return bool(self.resources or self.resource_templates or self.resource_capabilities) + + def invalidate_resource_cache(self, uri: str | None = None) -> None: + if uri is None: + self._resource_cache.clear() + return + for key in list(self._resource_cache.keys()): + if key[0] == uri: + self._resource_cache.pop(key, None) + + def resource_uri_allowed(self, uri: str) -> bool: + if any(item.get('uri') == uri for item in self.resources): + return True + + for template in self.resource_templates: + uri_template = template.get('uri_template', '') + if _uri_matches_template(uri, uri_template): + return True + + return False + + async def read_resource_envelope( + self, + uri: str, + *, + max_bytes: int = MCP_RESOURCE_PREVIEW_MAX_BYTES, + max_tokens: int | None = None, + include_blob: bool = False, + source: str = 'api', + query: pipeline_query.Query | None = None, + ) -> dict: + """Read a resource by URI with safety limits and audit metadata.""" + if not self.session: + raise Exception('MCP session is not connected') + + if not self.resource_uri_allowed(uri): + raise ValueError( + f'Resource URI is not available from MCP server {self.server_name!r}: {uri!r}. ' + 'Use listed resources or resource templates.' + ) + + cache_key = (uri, max_bytes, max_tokens, include_blob) + now = time.time() + cached = self._resource_cache.get(cache_key) + if cached and now - cached.get('cached_at', 0) <= MCP_RESOURCE_CACHE_TTL_SECONDS: + envelope = { + **cached['envelope'], + 'cache_hit': True, + 'source': source, + } + self._record_resource_read_trace(query, envelope) + return envelope + + result = await self.session.read_resource(AnyUrl(uri)) + contents: list[dict] = [] + total_bytes = 0 + truncated_any = False + warnings: list[str] = [] + remaining_bytes = max_bytes if max_bytes > 0 else None + remaining_tokens = max_tokens if max_tokens is not None and max_tokens > 0 else None + + for content in result.contents: + if isinstance(content, mcp_types.TextResourceContents): + if (remaining_bytes is not None and remaining_bytes <= 0) or ( + remaining_tokens is not None and remaining_tokens <= 0 + ): + text = '' + truncated = True + original_bytes = len(content.text.encode('utf-8')) + else: + text, truncated, original_bytes = _truncate_text( + content.text, + remaining_bytes if remaining_bytes is not None else 0, + remaining_tokens, + ) + total_bytes += original_bytes + truncated_any = truncated_any or truncated + if remaining_bytes is not None: + remaining_bytes = max(0, remaining_bytes - len(text.encode('utf-8'))) + if remaining_tokens is not None: + remaining_tokens = max(0, remaining_tokens - (max(1, len(text) // 4) if text else 0)) + contents.append( + { + 'uri': str(content.uri), + 'mime_type': content.mimeType or '', + 'type': 'text', + 'text': text, + 'bytes': original_bytes, + 'truncated': truncated, + '_meta': _mcp_model_dump(getattr(content, 'meta', None)) or {}, + } + ) + elif isinstance(content, mcp_types.BlobResourceContents): + original_bytes = _blob_size(content.blob) + total_bytes += original_bytes + include_this_blob = include_blob and (remaining_bytes is None or original_bytes <= remaining_bytes) + if not include_this_blob: + truncated_any = True + warnings.append('Binary resource content omitted from response.') + elif remaining_bytes is not None: + remaining_bytes = max(0, remaining_bytes - original_bytes) + contents.append( + { + 'uri': str(content.uri), + 'mime_type': content.mimeType or '', + 'type': 'blob', + 'blob': content.blob if include_this_blob else None, + 'bytes': original_bytes, + 'truncated': not include_this_blob, + 'binary_omitted': not include_this_blob, + '_meta': _mcp_model_dump(getattr(content, 'meta', None)) or {}, + } + ) + + envelope = { + 'server_name': self.server_name, + 'server_uuid': self.server_uuid, + 'uri': uri, + 'source': source, + 'contents': contents, + 'bytes': total_bytes, + 'truncated': truncated_any, + 'cache_hit': False, + 'warnings': warnings, + } + self._resource_cache[cache_key] = {'cached_at': now, 'envelope': envelope} + self._record_resource_read_trace(query, envelope) + return envelope + + def _record_resource_read_trace(self, query: pipeline_query.Query | None, envelope: dict) -> None: + if query is None: + return + try: + from langbot.pkg.telemetry import features as telemetry_features + + telemetry_features.increment(query, 'mcp_resource_reads', envelope.get('source') or 'unknown') + query.variables.setdefault(MCP_RESOURCE_TRACE_QUERY_KEY, []).append( + { + 'server_name': envelope.get('server_name'), + 'server_uuid': envelope.get('server_uuid'), + 'uri': envelope.get('uri'), + 'source': envelope.get('source'), + 'bytes': envelope.get('bytes', 0), + 'truncated': envelope.get('truncated', False), + 'cache_hit': envelope.get('cache_hit', False), + 'content_types': [item.get('type') for item in envelope.get('contents', [])], + } + ) + except Exception: + pass + + async def read_resource(self, uri: str) -> list[dict]: + """Read a resource by URI and return its capped contents.""" + envelope = await self.read_resource_envelope(uri) + return envelope['contents'] + def get_runtime_info_dict(self) -> dict: info = { 'status': self.status.value, @@ -409,6 +890,11 @@ class RuntimeMCPSession: } for tool in self.get_tools() ], + 'resource_count': len(self.get_resources()), + 'resources': self.get_resources(), + 'resource_template_count': len(self.get_resource_templates()), + 'resource_templates': self.get_resource_templates(), + 'resource_capabilities': self.resource_capabilities, } if self._uses_box_stdio(): info['box_session_id'] = self._build_box_session_id() @@ -575,8 +1061,164 @@ class MCPLoader(loader.ToolLoader): return session - async def get_tools(self, bound_mcp_servers: list[str] | None = None) -> list[resource_tool.LLMTool]: - all_functions = [] + @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 + + def _eligible_resource_sessions_for_bound(self, bound_mcp_servers: list[str] | None) -> list[RuntimeMCPSession]: + return [ + session + for session in self._eligible_sessions_for_bound(bound_mcp_servers) + if session.has_resource_support() + ] + + @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 resources and resource templates exposed by an MCP server. ' + 'Call langbot_mcp_read_resource with a listed resource URI or a URI constructed from a listed template. ' + '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 capped text content for a resource. Binary resources return metadata only. ' + 'Only read URIs exposed by langbot_mcp_list_resources for the bound server.' + ), + 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_resource_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() + templates = session.get_resource_templates() + body = { + 'server_name': server_name, + 'resource_count': len(data), + 'resources': data, + 'resource_template_count': len(templates), + 'resource_templates': templates, + 'resource_capabilities': session.resource_capabilities, + } + 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_resource_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: + envelope = await session.read_resource_envelope( + uri, + max_bytes=MCP_RESOURCE_AGENT_READ_MAX_BYTES, + max_tokens=MCP_RESOURCE_AGENT_READ_MAX_TOKENS, + include_blob=False, + source='agent_tool', + query=query, + ) + 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 envelope.get('contents', []): + if not isinstance(item, dict): + continue + t = item.get('type', '') + if t == 'text' and 'text' in item: + header = { + 'uri': item.get('uri'), + 'mime_type': item.get('mime_type', ''), + 'bytes': item.get('bytes', 0), + 'truncated': item.get('truncated', False), + } + out_chunks.append(f'{json.dumps(header, ensure_ascii=False)}\n{typing.cast(str, item["text"])}') + elif t == 'blob': + out_chunks.append( + json.dumps( + { + 'uri': item.get('uri'), + 'mime_type': item.get('mime_type', ''), + 'bytes': item.get('bytes', 0), + 'binary_omitted': True, + }, + ensure_ascii=False, + ) + ) + if not out_chunks: + return [provider_message.ContentElement.from_text(json.dumps(envelope, ensure_ascii=False, indent=2))] + suffix = '' + if envelope.get('truncated'): + suffix = '\n\n[LangBot: resource content was truncated by configured byte/token limits.]' + return [provider_message.ContentElement.from_text('\n\n'.join(out_chunks) + suffix)] + + async def get_tools( + self, + bound_mcp_servers: list[str] | None = None, + *, + include_resource_tools: bool = True, + ) -> list[resource_tool.LLMTool]: + 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 +1229,57 @@ class MCPLoader(loader.ToolLoader): # If no bound servers specified, include all tools all_functions.extend(session.get_tools()) + if include_resource_tools and self._eligible_resource_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 get_tool_catalog( + self, + bound_mcp_servers: list[str] | None = None, + *, + include_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + items: list[dict[str, typing.Any]] = [] + + for session in self.sessions.values(): + if bound_mcp_servers is not None and session.server_uuid not in bound_mcp_servers: + continue + for tool in session.get_tools(): + items.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': 'mcp', + 'source_name': session.server_name, + 'source_id': session.server_uuid, + } + ) + + if include_resource_tools and self._eligible_resource_sessions_for_bound(bound_mcp_servers): + for tool in self._mcp_synthetic_resource_tools(): + items.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': 'mcp', + 'source_name': 'MCP resources', + 'source_id': '', + } + ) + + return items + async def has_tool(self, name: str) -> bool: """检查工具是否存在""" + if name in (MCP_TOOL_LIST_RESOURCES, MCP_TOOL_READ_RESOURCE): + return bool(self._eligible_resource_sessions_for_bound(None)) for session in self.sessions.values(): for function in session.get_tools(): if function.name == name: @@ -608,12 +1295,21 @@ 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: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + return await self._invoke_mcp_list_resources(parameters, query) + if name == MCP_TOOL_READ_RESOURCE: + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return [provider_message.ContentElement.from_text('Error: MCP resource agent reads are disabled.')] + 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: self.ap.logger.debug(f'Invoking MCP tool: {name} with parameters: {parameters}') try: - result = await function.func(**parameters) + result = await session.invoke_mcp_tool(name, parameters, query=query) self.ap.logger.debug(f'MCP tool {name} executed successfully') return result except Exception as e: @@ -622,6 +1318,159 @@ 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 get_resource_templates(self, server_name: str) -> list[dict]: + """Get resource templates 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_resource_templates() + + async def read_resource_envelope( + self, + server_name: str, + uri: str, + *, + max_bytes: int = MCP_RESOURCE_PREVIEW_MAX_BYTES, + max_tokens: int | None = None, + include_blob: bool = False, + source: str = 'api', + query: pipeline_query.Query | None = None, + ) -> dict: + """Read a resource from a specific MCP server and return metadata plus contents.""" + session = self.get_session(server_name) + if session is None: + raise ValueError(f'MCP server not found: {server_name}') + return await session.read_resource_envelope( + uri, + max_bytes=max_bytes, + max_tokens=max_tokens, + include_blob=include_blob, + source=source, + query=query, + ) + + async def read_resource(self, server_name: str, uri: str) -> list[dict]: + """Read a resource from a specific MCP server.""" + envelope = await self.read_resource_envelope(server_name, uri) + return envelope['contents'] + + def get_session_by_uuid(self, server_uuid: str) -> RuntimeMCPSession | None: + for session in self.sessions.values(): + if session.server_uuid == server_uuid: + return session + return None + + def _resolve_attachment_session(self, attachment: dict) -> RuntimeMCPSession | None: + server_uuid = attachment.get('server_uuid') or attachment.get('server_id') + server_name = attachment.get('server_name') + if server_uuid: + return self.get_session_by_uuid(server_uuid) + if server_name: + return self.get_session(server_name) + return None + + async def build_resource_context_for_query( + self, + query: pipeline_query.Query, + *, + default_max_tokens: int = MCP_RESOURCE_CONTEXT_MAX_TOKENS, + default_max_bytes: int = MCP_RESOURCE_CONTEXT_MAX_BYTES, + ) -> str: + """Build host-controlled MCP resource context for the current query.""" + if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is False: + return '' + + attachments = (query.variables or {}).get('_pipeline_mcp_resource_attachments', []) + if not isinstance(attachments, list) or not attachments: + return '' + + bound = self._get_bound_mcp_from_query(query) + eligible = self._eligible_resource_sessions_for_bound(bound) + eligible_by_uuid = {session.server_uuid: session for session in eligible} + eligible_by_name = {session.server_name: session for session in eligible} + + blocks: list[str] = [] + remaining_tokens = default_max_tokens + + for raw_attachment in attachments: + if remaining_tokens <= 0: + break + if not isinstance(raw_attachment, dict) or raw_attachment.get('enabled') is False: + continue + + attachment = raw_attachment.copy() + mode = attachment.get('mode', 'pinned') + if mode not in ('pinned', 'manual', 'auto'): + continue + + uri = attachment.get('uri') + if not uri or not isinstance(uri, str): + continue + + session = self._resolve_attachment_session(attachment) + if session is None: + continue + if session.server_uuid not in eligible_by_uuid and session.server_name not in eligible_by_name: + continue + + max_tokens = min(int(attachment.get('max_tokens') or remaining_tokens), remaining_tokens) + max_bytes = int(attachment.get('max_bytes') or default_max_bytes) + + try: + envelope = await session.read_resource_envelope( + uri, + max_bytes=max_bytes, + max_tokens=max_tokens, + include_blob=False, + source='preloaded', + query=query, + ) + except Exception as e: + self.ap.logger.warning(f'Failed to preload MCP resource {uri!r} from {session.server_name!r}: {e}') + continue + + for item in envelope.get('contents', []): + if item.get('type') != 'text': + continue + mime_type = item.get('mime_type', '') + text = item.get('text') or '' + if not text: + continue + approx_tokens = max(1, len(text) // 4) + remaining_tokens -= approx_tokens + header_attrs = { + 'server': session.server_name, + 'server_uuid': session.server_uuid, + 'uri': item.get('uri') or uri, + 'mime_type': mime_type, + 'bytes': item.get('bytes', 0), + 'truncated': item.get('truncated', False), + 'mode': mode, + } + attr_text = ' '.join(f'{k}={json.dumps(v, ensure_ascii=False)}' for k, v in header_attrs.items()) + blocks.append(f'\n{text}\n') + if remaining_tokens <= 0: + break + + context = '\n\n'.join(blocks) + if context: + try: + query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY] = { + 'resource_count': len(blocks), + 'max_tokens': default_max_tokens, + 'traces': query.variables.get(MCP_RESOURCE_TRACE_QUERY_KEY, []), + } + except Exception: + pass + return context + async def remove_mcp_server(self, server_name: str): """移除 MCP 服务器""" if server_name not in self.sessions: diff --git a/src/langbot/pkg/provider/tools/loaders/plugin.py b/src/langbot/pkg/provider/tools/loaders/plugin.py index 544882d34..baac91d1d 100644 --- a/src/langbot/pkg/provider/tools/loaders/plugin.py +++ b/src/langbot/pkg/provider/tools/loaders/plugin.py @@ -33,6 +33,24 @@ class PluginToolLoader(loader.ToolLoader): return all_functions + async def get_tool_catalog(self, bound_plugins: list[str] | None = None) -> list[dict[str, typing.Any]]: + catalog: list[dict[str, typing.Any]] = [] + + for tool in await self.ap.plugin_connector.list_tools(bound_plugins): + catalog.append( + { + 'name': tool.metadata.name, + 'description': tool.spec['llm_prompt'], + 'human_desc': tool.metadata.description.en_US, + 'parameters': tool.spec['parameters'], + 'source': 'plugin', + 'source_name': tool.owner, + 'source_id': tool.owner, + } + ) + + return catalog + async def has_tool(self, name: str) -> bool: """检查工具是否存在""" for tool in await self.ap.plugin_connector.list_tools(): diff --git a/src/langbot/pkg/provider/tools/toolmgr.py b/src/langbot/pkg/provider/tools/toolmgr.py index 38b08aa19..642024824 100644 --- a/src/langbot/pkg/provider/tools/toolmgr.py +++ b/src/langbot/pkg/provider/tools/toolmgr.py @@ -59,6 +59,7 @@ class ToolManager: bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None, include_skill_authoring: bool = False, + include_mcp_resource_tools: bool = True, ) -> list[resource_tool.LLMTool]: all_functions: list[resource_tool.LLMTool] = [] @@ -66,10 +67,51 @@ class ToolManager: if include_skill_authoring: all_functions.extend(await self.skill_tool_loader.get_tools()) all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins)) - all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers)) + all_functions.extend( + await self.mcp_tool_loader.get_tools( + bound_mcp_servers, + include_resource_tools=include_mcp_resource_tools, + ) + ) return all_functions + async def get_tool_catalog( + self, + bound_plugins: list[str] | None = None, + bound_mcp_servers: list[str] | None = None, + include_skill_authoring: bool = False, + include_mcp_resource_tools: bool = False, + ) -> list[dict[str, typing.Any]]: + catalog: list[dict[str, typing.Any]] = [] + + def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None: + for tool in tools: + catalog.append( + { + 'name': tool.name, + 'description': tool.description, + 'human_desc': tool.human_desc, + 'parameters': tool.parameters, + 'source': source, + 'source_name': source_name, + } + ) + + append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools()) + if include_skill_authoring: + append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools()) + catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins)) + + if self.mcp_tool_loader: + for item in await self.mcp_tool_loader.get_tool_catalog( + bound_mcp_servers, + include_resource_tools=include_mcp_resource_tools, + ): + catalog.append(item) + + return catalog + async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None: """Get tool by name from any active loader.""" for active_loader in ( diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml index 00c041c75..b5c5eb79f 100644 --- a/src/langbot/templates/metadata/pipeline/ai.yaml +++ b/src/langbot/templates/metadata/pipeline/ai.yaml @@ -118,20 +118,6 @@ stages: default: - role: system content: "You are a helpful assistant." - - name: knowledge-bases - label: - en_US: Knowledge Bases - zh_Hans: 知识库 - description: - en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply - zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复 - type: knowledge-base-multi-selector - required: false - default: [] - show_if: - field: __system.is_wizard - operator: neq - value: true - name: box-session-id-template label: en_US: Sandbox Scope @@ -254,6 +240,34 @@ stages: field: rerank-model operator: neq value: '' + - name: tools + label: + en_US: Tools + zh_Hans: 工具 + description: + en_US: Select plugin, MCP, skill, and built-in tools available to this Local Agent. + zh_Hans: 选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。 + type: rich-tools-selector + required: false + default: [] + show_if: + field: __system.is_wizard + operator: neq + value: true + - name: knowledge-bases + label: + en_US: Resources + zh_Hans: 资源 + description: + en_US: Select MCP resources and knowledge bases available to this Local Agent. + zh_Hans: 选择此内置 Agent 可以读取的 MCP 资源和知识库。 + type: resources-selector + required: false + default: [] + show_if: + field: __system.is_wizard + operator: neq + value: true - name: dify-service-api label: en_US: Dify Service API diff --git a/tests/unit_tests/api/service/test_mcp_service.py b/tests/unit_tests/api/service/test_mcp_service.py index 17c746e73..8e3e6cd98 100644 --- a/tests/unit_tests/api/service/test_mcp_service.py +++ b/tests/unit_tests/api/service/test_mcp_service.py @@ -90,6 +90,56 @@ class TestMCPServiceGetRuntimeInfo: assert result is None +class TestMCPServiceResources: + """Tests for MCP resource helpers.""" + + async def test_get_resource_templates_delegates_to_loader(self): + ap = SimpleNamespace() + ap.tool_mgr = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock( + return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}] + ) + + service = MCPService(ap) + + result = await service.get_mcp_server_resource_templates('docs') + + assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}] + ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs') + + async def test_read_resource_envelope_uses_ui_preview_source(self): + ap = SimpleNamespace() + ap.tool_mgr = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader = SimpleNamespace() + ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock( + return_value={ + 'server_name': 'docs', + 'uri': 'file:///README.md', + 'contents': [], + 'source': 'ui_preview', + } + ) + + service = MCPService(ap) + + result = await service.read_mcp_server_resource_envelope( + 'docs', + 'file:///README.md', + max_bytes=4096, + include_blob=True, + ) + + assert result['source'] == 'ui_preview' + ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with( + 'docs', + 'file:///README.md', + include_blob=True, + source='ui_preview', + max_bytes=4096, + ) + + class TestMCPServiceGetMCPServers: """Tests for get_mcp_servers method.""" diff --git a/tests/unit_tests/api/service/test_pipeline_service.py b/tests/unit_tests/api/service/test_pipeline_service.py index 28d2fc117..fade30372 100644 --- a/tests/unit_tests/api/service/test_pipeline_service.py +++ b/tests/unit_tests/api/service/test_pipeline_service.py @@ -348,6 +348,8 @@ class TestPipelineServiceCreatePipeline: 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': [], + 'mcp_resources': [], + 'mcp_resource_agent_read_enabled': True, } @@ -814,6 +816,47 @@ class TestPipelineServiceUpdatePipelineExtensions: # Verify - persistence was called ap.persistence_mgr.execute_async.assert_called() + async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self): + """Does not reset mcp_resource_agent_read_enabled when omitted by older clients.""" + ap = SimpleNamespace() + ap.persistence_mgr = SimpleNamespace() + ap.pipeline_mgr = SimpleNamespace() + ap.pipeline_mgr.remove_pipeline = AsyncMock() + ap.pipeline_mgr.load_pipeline = AsyncMock() + + original_pipeline = _create_mock_pipeline( + extensions_preferences={ + 'enable_all_plugins': True, + 'enable_all_mcp_servers': True, + 'plugins': [], + 'mcp_servers': [], + 'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}], + 'mcp_resource_agent_read_enabled': False, + } + ) + + call_count = 0 + + async def mock_execute(query): + nonlocal call_count + call_count += 1 + if call_count == 1: + return _create_mock_result(first_item=original_pipeline) + return Mock() + + ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) + ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'}) + + service = PipelineService(ap) + service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'}) + + await service.update_pipeline_extensions('test-uuid', bound_plugins=[]) + + assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False + assert original_pipeline.extensions_preferences['mcp_resources'] == [ + {'server_uuid': 'srv-1', 'uri': 'file:///README.md'} + ] + class TestDefaultStageOrder: """Tests for default_stage_order constant.""" diff --git a/tests/unit_tests/pipeline/test_pipelinemgr.py b/tests/unit_tests/pipeline/test_pipelinemgr.py index f2e6780d6..49984542c 100644 --- a/tests/unit_tests/pipeline/test_pipelinemgr.py +++ b/tests/unit_tests/pipeline/test_pipelinemgr.py @@ -162,3 +162,46 @@ async def test_runtime_pipeline_execute(mock_app, sample_query): # Verify stage was called mock_stage.process.assert_called_once() + + +def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app): + """Local Agent resource selection should override legacy extension prefs.""" + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = { + 'ai': { + 'local-agent': { + 'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}], + 'mcp-resource-agent-read-enabled': False, + } + } + } + pipeline_entity.extensions_preferences = { + 'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}], + 'mcp_resource_agent_read_enabled': True, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False + + +def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app): + """Existing extension prefs remain compatible until a Local Agent value exists.""" + pipelinemgr = get_pipelinemgr_module() + persistence_pipeline = get_persistence_pipeline_module() + + pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline) + pipeline_entity.config = {'ai': {'local-agent': {}}} + pipeline_entity.extensions_preferences = { + 'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}], + 'mcp_resource_agent_read_enabled': False, + } + + runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, []) + + assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}] + assert runtime_pipeline.mcp_resource_agent_read_enabled is False diff --git a/tests/unit_tests/pipeline/test_preproc.py b/tests/unit_tests/pipeline/test_preproc.py index 9cbf65265..15bf60801 100644 --- a/tests/unit_tests/pipeline/test_preproc.py +++ b/tests/unit_tests/pipeline/test_preproc.py @@ -14,6 +14,7 @@ from __future__ import annotations import pytest from unittest.mock import AsyncMock, Mock from importlib import import_module +from types import SimpleNamespace from tests.factories import ( FakeApp, @@ -431,3 +432,60 @@ class TestPreProcessorVariables: variables = result.new_query.variables assert 'group_name' in variables assert 'sender_name' in variables + + +class TestPreProcessorToolSelection: + """Tests for Local Agent tool selection.""" + + @pytest.mark.asyncio + async def test_local_agent_filters_selected_tools(self): + """Only selected tools should be exposed when all-tools mode is off.""" + preproc = get_preproc_module() + + app = FakeApp() + mock_session = Mock() + mock_session.launcher_type = Mock(value='person') + mock_session.launcher_id = 12345 + app.sess_mgr.get_session = AsyncMock(return_value=mock_session) + + mock_conversation = Mock() + mock_conversation.prompt = Mock(messages=[]) + mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[])) + mock_conversation.messages = [] + mock_conversation.uuid = None + app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation) + + mock_model = Mock() + mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call']) + app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model) + app.tool_mgr.get_all_tools = AsyncMock( + return_value=[ + SimpleNamespace(name='exec'), + SimpleNamespace(name='plugin_tool'), + SimpleNamespace(name='mcp_tool'), + ] + ) + + mock_event_ctx = Mock() + mock_event_ctx.event = Mock(default_prompt=[], prompt=[]) + app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx) + + stage = preproc.PreProcessor(app) + query = text_query('hello') + query.pipeline_config = { + 'ai': { + 'runner': {'runner': 'local-agent'}, + 'local-agent': { + 'model': {'primary': 'primary-model-uuid', 'fallbacks': []}, + 'prompt': 'default', + 'enable-all-tools': False, + 'tools': ['plugin_tool'], + }, + }, + 'output': {'misc': {'at-sender': False}}, + 'trigger': {'misc': {}}, + } + + result = await stage.process(query, 'PreProcessor') + + assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool'] diff --git a/tests/unit_tests/provider/test_mcp_resources.py b/tests/unit_tests/provider/test_mcp_resources.py new file mode 100644 index 000000000..565422c46 --- /dev/null +++ b/tests/unit_tests/provider/test_mcp_resources.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import base64 +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from mcp import types as mcp_types + +from langbot.pkg.provider.tools.loaders.mcp import ( + MCP_RESOURCE_CONTEXT_QUERY_KEY, + MCP_RESOURCE_TRACE_QUERY_KEY, + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + MCPLoader, + MCPSessionStatus, + RuntimeMCPSession, +) +from langbot.pkg.telemetry import features as telemetry_features + + +def _app() -> SimpleNamespace: + return SimpleNamespace(logger=Mock()) + + +def _connected_session( + *, + name: str = 'docs', + uuid: str = 'srv-1', + resources: list[dict] | None = None, + templates: list[dict] | None = None, +) -> RuntimeMCPSession: + session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app()) + session.status = MCPSessionStatus.CONNECTED + session.session = SimpleNamespace(read_resource=AsyncMock()) + session.resources = resources or [ + { + 'uri': 'file:///README.md', + 'name': 'README.md', + 'title': '', + 'description': '', + 'mime_type': 'text/markdown', + 'size': None, + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ] + session.resource_templates = templates or [] + return session + + +def _query() -> SimpleNamespace: + return SimpleNamespace(variables={}) + + +@pytest.mark.asyncio +async def test_read_resource_envelope_truncates_caches_and_records_trace(): + session = _connected_session() + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='abcdef', + ) + ] + ) + query = _query() + + first = await session.read_resource_envelope( + 'file:///README.md', + max_bytes=4, + source='ui_preview', + query=query, + ) + second = await session.read_resource_envelope( + 'file:///README.md', + max_bytes=4, + source='agent_tool', + query=query, + ) + + assert first['contents'][0]['text'] == 'abcd' + assert first['contents'][0]['bytes'] == 6 + assert first['truncated'] is True + assert first['cache_hit'] is False + assert second['cache_hit'] is True + assert second['source'] == 'agent_tool' + assert session.session.read_resource.await_count == 1 + + traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY] + assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool'] + assert traces[1]['cache_hit'] is True + assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == { + 'ui_preview': 1, + 'agent_tool': 1, + } + + +@pytest.mark.asyncio +async def test_read_resource_envelope_shares_byte_budget_across_text_contents(): + session = _connected_session() + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md#first', + mimeType='text/plain', + text='abc', + ), + mcp_types.TextResourceContents( + uri='file:///README.md#second', + mimeType='text/plain', + text='def', + ), + ] + ) + + envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4) + + assert [item['text'] for item in envelope['contents']] == ['abc', 'd'] + assert envelope['contents'][0]['truncated'] is False + assert envelope['contents'][1]['truncated'] is True + assert envelope['bytes'] == 6 + assert envelope['truncated'] is True + + +@pytest.mark.asyncio +async def test_read_resource_envelope_omits_binary_by_default(): + session = _connected_session( + resources=[ + { + 'uri': 'file:///image.png', + 'name': 'image.png', + 'title': '', + 'description': '', + 'mime_type': 'image/png', + 'size': 4, + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ] + ) + session.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.BlobResourceContents( + uri='file:///image.png', + mimeType='image/png', + blob=base64.b64encode(b'\x00\x01\x02\x03').decode(), + ) + ] + ) + + envelope = await session.read_resource_envelope('file:///image.png') + + content = envelope['contents'][0] + assert content['type'] == 'blob' + assert content['blob'] is None + assert content['bytes'] == 4 + assert content['binary_omitted'] is True + assert envelope['truncated'] is True + assert envelope['warnings'] == ['Binary resource content omitted from response.'] + + +@pytest.mark.asyncio +async def test_read_resource_envelope_rejects_unlisted_uri(): + session = _connected_session() + + with pytest.raises(ValueError, match='Resource URI is not available'): + await session.read_resource_envelope('file:///secret.txt') + + session.session.read_resource.assert_not_called() + + +def test_resource_uri_allowed_supports_listed_templates_conservatively(): + session = _connected_session( + resources=[], + templates=[ + { + 'uri_template': 'repo://{owner}/{repo}/file/{path}', + 'name': 'repository file', + 'title': '', + 'description': '', + 'mime_type': 'text/plain', + 'icons': [], + 'annotations': {}, + '_meta': {}, + } + ], + ) + + assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True + assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False + assert session.resource_uri_allowed('https://example.com/secret') is False + + +@pytest.mark.asyncio +async def test_mcp_loader_can_hide_synthetic_resource_tools(): + loader = MCPLoader(_app()) + session = _connected_session() + loader.sessions = {'docs': session} + + with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True) + without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False) + + assert {tool.name for tool in with_resource_tools} == { + MCP_TOOL_LIST_RESOURCES, + MCP_TOOL_READ_RESOURCE, + } + assert without_resource_tools == [] + + +@pytest.mark.asyncio +async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled(): + loader = MCPLoader(_app()) + session = _connected_session() + loader.sessions = {'docs': session} + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_agent_read_enabled': False, + } + ) + + result = await loader.invoke_tool( + MCP_TOOL_READ_RESOURCE, + {'server_name': 'docs', 'uri': 'file:///README.md'}, + query, + ) + + assert result[0].text == 'Error: MCP resource agent reads are disabled.' + session.session.read_resource.assert_not_called() + + +@pytest.mark.asyncio +async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources(): + loader = MCPLoader(_app()) + docs = _connected_session(name='docs', uuid='srv-1') + docs.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='LangBot MCP resource context', + ) + ] + ) + other = _connected_session(name='other', uuid='srv-2') + other.session.read_resource.return_value = mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri='file:///README.md', + mimeType='text/markdown', + text='must not be injected', + ) + ] + ) + loader.sessions = {'docs': docs, 'other': other} + query = SimpleNamespace( + variables={ + '_pipeline_bound_mcp_servers': ['srv-1'], + '_pipeline_mcp_resource_attachments': [ + {'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'}, + {'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'}, + ], + } + ) + + context = await loader.build_resource_context_for_query(query) + + assert ' bool: return any(tool.name == name for tool in self._tools) @@ -68,6 +90,28 @@ async def test_tool_manager_includes_skill_authoring_tools_when_requested(): assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool'] +@pytest.mark.asyncio +async def test_tool_manager_catalog_labels_tool_sources(): + manager = ToolManager(SimpleNamespace()) + manager.native_tool_loader = StubLoader([make_tool('exec')]) + manager.skill_tool_loader = StubLoader([make_tool('activate')]) + manager.plugin_tool_loader = StubLoader( + [make_tool('plugin_tool')], + catalog_source='plugin', + catalog_source_name='fixture-plugin', + ) + manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')]) + + catalog = await manager.get_tool_catalog(include_skill_authoring=True) + + assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [ + ('exec', 'builtin', 'LangBot'), + ('activate', 'skill', 'LangBot'), + ('plugin_tool', 'plugin', 'fixture-plugin'), + ('mcp_tool', 'mcp', 'fixture-server'), + ] + + @pytest.mark.asyncio async def test_tool_manager_routes_native_tool_calls(): app = SimpleNamespace() diff --git a/tests/unit_tests/test_preproc.py b/tests/unit_tests/test_preproc.py index 3164f35b8..8f05277cb 100644 --- a/tests/unit_tests/test_preproc.py +++ b/tests/unit_tests/test_preproc.py @@ -118,7 +118,12 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl result = await stage.process(_make_query(), 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True) + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + include_mcp_resource_tools=True, + ) @pytest.mark.asyncio @@ -131,7 +136,32 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing result = await stage.process(_make_query(), 'PreProcessor') assert result.result_type == entities_module.ResultType.CONTINUE - app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False) + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=False, + include_mcp_resource_tools=True, + ) + + +@pytest.mark.asyncio +async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled(): + preproc_module, entities_module = _import_preproc_modules() + + app = _make_app(skill_service=SimpleNamespace()) + stage = preproc_module.PreProcessor(app) + query = _make_query() + query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False + + result = await stage.process(query, 'PreProcessor') + + assert result.result_type == entities_module.ResultType.CONTINUE + app.tool_mgr.get_all_tools.assert_awaited_once_with( + None, + None, + include_skill_authoring=True, + include_mcp_resource_tools=False, + ) @pytest.mark.asyncio diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index a9c4810b0..da1860213 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -1,4 +1,5 @@ import { + DynamicFormItemType, IDynamicFormItemSchema, SYSTEM_FIELD_PREFIX, } from '@/app/infra/entities/form/dynamic'; @@ -57,6 +58,95 @@ function resolveShowIfValue( return externalDependentValues?.[field]; } +type DynamicFormValueSpec = Pick< + IDynamicFormItemSchema, + 'default' | 'name' | 'required' | 'type' +>; + +function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] { + if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) { + return [ + item, + { + name: 'enable-all-tools', + type: DynamicFormItemType.BOOLEAN, + required: false, + default: true, + }, + ]; + } + + if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) { + return [ + item, + { + name: 'mcp-resources', + type: DynamicFormItemType.UNKNOWN, + required: false, + default: [], + }, + { + name: 'mcp-resource-agent-read-enabled', + type: DynamicFormItemType.BOOLEAN, + required: false, + default: true, + }, + ]; + } + + return [item]; +} + +function getValueSchema(spec: DynamicFormValueSpec) { + if (spec.name === 'mcp-resources') { + return z.array(z.any()); + } + + switch (spec.type) { + case DynamicFormItemType.INT: + return z.number(); + case DynamicFormItemType.FLOAT: + return z.number(); + case DynamicFormItemType.BOOLEAN: + return z.boolean(); + case DynamicFormItemType.STRING: + return z.string(); + case DynamicFormItemType.STRING_ARRAY: + return z.array(z.string()); + case DynamicFormItemType.SELECT: + return z.string(); + case DynamicFormItemType.LLM_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.RERANK_MODEL_SELECTOR: + return z.string(); + case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR: + return z.string(); + case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR: + case DynamicFormItemType.RESOURCES_SELECTOR: + case DynamicFormItemType.RICH_TOOLS_SELECTOR: + case DynamicFormItemType.TOOLS_SELECTOR: + return z.array(z.string()); + case DynamicFormItemType.BOT_SELECTOR: + return z.string(); + case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: + return z.object({ + primary: z.string(), + fallbacks: z.array(z.string()), + }); + case DynamicFormItemType.PROMPT_EDITOR: + return z.array( + z.object({ + content: z.string(), + role: z.string(), + }), + ); + default: + return z.string(); + } +} + /** * Display-only component for embed code fields with copy animation. */ @@ -322,9 +412,16 @@ export default function DynamicFormComponent({ // model-fallback-selector) is coerced to the expected shape // so that downstream components never crash. const normalizeFieldValue = ( - item: IDynamicFormItemSchema, + item: DynamicFormValueSpec, value: unknown, ): unknown => { + if ( + item.name === 'mcp-resources' || + item.type === DynamicFormItemType.RESOURCES_SELECTOR || + item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR + ) { + return Array.isArray(value) ? value : []; + } if (item.type === 'model-fallback-selector') { if (value != null && typeof value === 'object' && !Array.isArray(value)) { const obj = value as Record; @@ -368,68 +465,16 @@ export default function DynamicFormComponent({ [itemConfigList], ); + const editableValueSpecs = useMemo( + () => editableItems.flatMap(getValueSpecs), + [editableItems], + ); + // 根据 itemConfigList 动态生成 zod schema const formSchema = z.object( - editableItems.reduce( + editableValueSpecs.reduce( (acc, item) => { - let fieldSchema; - switch (item.type) { - case 'integer': - fieldSchema = z.number(); - break; - case 'float': - fieldSchema = z.number(); - break; - case 'boolean': - fieldSchema = z.boolean(); - break; - case 'string': - fieldSchema = z.string(); - break; - case 'array[string]': - fieldSchema = z.array(z.string()); - break; - case 'select': - fieldSchema = z.string(); - break; - case 'llm-model-selector': - fieldSchema = z.string(); - break; - case 'embedding-model-selector': - fieldSchema = z.string(); - break; - case 'rerank-model-selector': - fieldSchema = z.string(); - break; - case 'knowledge-base-selector': - fieldSchema = z.string(); - break; - case 'knowledge-base-multi-selector': - fieldSchema = z.array(z.string()); - break; - case 'bot-selector': - fieldSchema = z.string(); - break; - case 'tools-selector': - fieldSchema = z.array(z.string()); - break; - case 'model-fallback-selector': - fieldSchema = z.object({ - primary: z.string(), - fallbacks: z.array(z.string()), - }); - break; - case 'prompt-editor': - fieldSchema = z.array( - z.object({ - content: z.string(), - role: z.string(), - }), - ); - break; - default: - fieldSchema = z.string(); - } + let fieldSchema = getValueSchema(item); if ( item.required && @@ -454,7 +499,7 @@ export default function DynamicFormComponent({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: editableItems.reduce((acc, item) => { + defaultValues: editableValueSpecs.reduce((acc, item) => { // 优先使用 initialValues,如果没有则使用默认值 const rawValue = initialValues?.[item.name] ?? item.default; return { @@ -493,7 +538,7 @@ export default function DynamicFormComponent({ if (initialValues && hasRealChange) { // 合并默认值和初始值 - const mergedValues = editableItems.reduce( + const mergedValues = editableValueSpecs.reduce( (acc, item) => { const rawValue = initialValues[item.name] ?? item.default; acc[item.name] = normalizeFieldValue(item, rawValue) as object; @@ -508,10 +553,16 @@ export default function DynamicFormComponent({ previousInitialValues.current = initialValues; } - }, [initialValues, form, editableItems]); + }, [initialValues, form, editableValueSpecs]); // Get reactive form values for conditional rendering const watchedValues = form.watch(); + const setFormValue = (name: string, value: unknown) => { + form.setValue(name as keyof FormValues, value as never, { + shouldDirty: true, + shouldValidate: true, + }); + }; // Stable ref for onSubmit to avoid re-triggering the effect when the // parent passes a new closure on every render. @@ -524,7 +575,7 @@ export default function DynamicFormComponent({ // even if the user saves without modifying any field. // form.watch(callback) only fires on subsequent changes, not on mount. const formValues = form.getValues(); - const initialFinalValues = editableItems.reduce( + const initialFinalValues = editableValueSpecs.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -544,7 +595,7 @@ export default function DynamicFormComponent({ const subscription = form.watch(() => { const formValues = form.getValues(); - const finalValues = editableItems.reduce( + const finalValues = editableValueSpecs.reduce( (acc, item) => { acc[item.name] = formValues[item.name] ?? item.default; return acc; @@ -555,7 +606,7 @@ export default function DynamicFormComponent({ previousInitialValues.current = finalValues as Record; }); return () => subscription.unsubscribe(); - }, [form, editableItems]); + }, [form, editableValueSpecs]); // State for QR code login dialog const [qrDialogOpen, setQrDialogOpen] = useState(false); @@ -786,6 +837,41 @@ export default function DynamicFormComponent({ ); } + if ( + config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR || + config.type === DynamicFormItemType.RESOURCES_SELECTOR + ) { + return ( + ( + + +
+ } + onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} + /> +
+
+ +
+ )} + /> + ); + } + // Boolean fields use a special inline layout if (config.type === 'boolean') { return ( @@ -816,7 +902,10 @@ export default function DynamicFormComponent({ } onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} /> @@ -853,7 +942,10 @@ export default function DynamicFormComponent({ } onFileUploaded={onFileUploaded} + setFormValue={setFormValue} + systemContext={systemContext} /> diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index ce125c70b..18f0e7452 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -64,15 +64,23 @@ import { import SettingsDialog, { SettingsSection, } from '@/app/home/components/settings-dialog/SettingsDialog'; +import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors'; +import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types'; export default function DynamicFormItemComponent({ config, field, + formValues, onFileUploaded, + setFormValue, + systemContext, }: { config: IDynamicFormItemSchema; field: ControllerRenderProps; + formValues?: Record; onFileUploaded?: (fileKey: string) => void; + setFormValue?: (name: string, value: unknown) => void; + systemContext?: Record; }) { const [llmModels, setLlmModels] = useState([]); const [embeddingModels, setEmbeddingModels] = useState([]); @@ -103,10 +111,34 @@ export default function DynamicFormItemComponent({ }); }; + const fetchEmbeddingModels = () => { + httpClient + .getProviderEmbeddingModels() + .then((resp) => { + setEmbeddingModels(resp.models); + }) + .catch((err) => { + toast.error(t('embedding.getModelListError') + err.msg); + }); + }; + + const fetchRerankModels = () => { + httpClient + .getProviderRerankModels() + .then((resp) => { + setRerankModels(resp.models); + }) + .catch((err) => { + toast.error('Failed to load rerank models: ' + err.msg); + }); + }; + const handleModelsDialogChange = (open: boolean) => { setModelsDialogOpen(open); if (!open) { fetchLlmModels(); + fetchEmbeddingModels(); + fetchRerankModels(); } }; @@ -174,27 +206,13 @@ export default function DynamicFormItemComponent({ useEffect(() => { if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) { - httpClient - .getProviderEmbeddingModels() - .then((resp) => { - setEmbeddingModels(resp.models); - }) - .catch((err) => { - toast.error(t('embedding.getModelListError') + err.msg); - }); + fetchEmbeddingModels(); } }, [config.type]); useEffect(() => { if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) { - httpClient - .getProviderRerankModels() - .then((resp) => { - setRerankModels(resp.models); - }) - .catch((err) => { - toast.error('Failed to load rerank models: ' + err.msg); - }); + fetchRerankModels(); } }, [config.type]); @@ -248,6 +266,16 @@ export default function DynamicFormItemComponent({ } }, [config.type]); + const handleCompositePatch = (patch: Record) => { + for (const [name, value] of Object.entries(patch)) { + if (setFormValue) { + setFormValue(name, value); + } else if (name === field.name) { + field.onChange(value); + } + } + }; + switch (config.type) { case DynamicFormItemType.INT: case DynamicFormItemType.FLOAT: @@ -377,10 +405,10 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.LLM_MODEL_SELECTOR: // Separate space models from regular models const spaceModels = llmModels.filter( - (m) => m.provider?.requester === 'space-chat-completions', + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, ); const regularModels = llmModels.filter( - (m) => m.provider?.requester !== 'space-chat-completions', + (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, ); // Group regular models by provider @@ -481,7 +509,7 @@ export default function DynamicFormItemComponent({ ))} {/* Blurred remaining models with login overlay */} -
+
setModelsDialogOpen(true)} + onClick={() => { + setSettingsSection('models'); + setModelsDialogOpen(true); + }} > @@ -574,9 +605,15 @@ export default function DynamicFormItemComponent({
); - case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: - // Group embedding models by provider - const groupedEmbeddingModels = embeddingModels.reduce( + case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: { + const spaceEmbeddingModels = embeddingModels.filter( + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + const regularEmbeddingModels = embeddingModels.filter( + (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, + ); + + const groupedEmbeddingModels = regularEmbeddingModels.reduce( (acc, model) => { const providerName = model.provider?.name || 'Unknown'; if (!acc[providerName]) acc[providerName] = []; @@ -586,29 +623,169 @@ export default function DynamicFormItemComponent({ {} as Record, ); + const groupedSpaceEmbeddingModels = spaceEmbeddingModels.reduce( + (acc, model) => { + const providerName = + model.provider?.name || model.provider?.requester || 'Unknown'; + if (!acc[providerName]) acc[providerName] = []; + acc[providerName].push(model); + return acc; + }, + {} as Record, + ); + + const previewEmbeddingModelNames = [ + 'text-embedding-3-large', + 'text-embedding-3-small', + 'bge-m3', + 'jina-embeddings-v3', + 'qwen3-embedding-8b', + ]; + return ( -
- + + + + + {Object.entries(groupedEmbeddingModels).map( + ([providerName, models]) => ( + + {providerName} + {models.map((model) => ( + + {model.name} + + ))} + + ), + )} + {showSpaceLoginCTA ? ( + + + + + {t('models.langbotModels')} + + e.preventDefault()} + > + + + + {t('models.spaceTrialTooltip')} + + + + +
e.preventDefault()} + > + {(spaceEmbeddingModels.length > 0 + ? spaceEmbeddingModels.map((m) => m.name) + : previewEmbeddingModelNames + ) + .slice(0, 3) + .map((name) => ( +
+ {name} +
+ ))} +
+
+ {(spaceEmbeddingModels.length > 0 + ? spaceEmbeddingModels.map((m) => m.name) + : previewEmbeddingModelNames + ) + .slice(3) + .map((name) => ( +
+ {name} +
+ ))} +
+
+ +
+
+
- ), - )} -
- + ) : !systemInfo.disable_models_service ? ( + Object.entries(groupedSpaceEmbeddingModels).map( + ([providerName, models]) => ( + + + + + {providerName} + + + {models.map((model) => ( + + {model.name} + + ))} + + ), + ) + ) : null} + + +
+ + + + + {t('models.title')} + +
); + } case DynamicFormItemType.RERANK_MODEL_SELECTOR: const groupedRerankModels = rerankModels.reduce( @@ -652,10 +829,10 @@ export default function DynamicFormItemComponent({ case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { // Separate space models from regular models const fbSpaceModels = llmModels.filter( - (m) => m.provider?.requester === 'space-chat-completions', + (m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER, ); const fbRegularModels = llmModels.filter( - (m) => m.provider?.requester !== 'space-chat-completions', + (m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER, ); // Group regular models by provider @@ -786,7 +963,7 @@ export default function DynamicFormItemComponent({
))} {/* Blurred remaining models with login overlay */} -
+
); + case DynamicFormItemType.RICH_TOOLS_SELECTOR: + return ( + + ); + + case DynamicFormItemType.RESOURCES_SELECTOR: + return ( + + ); + case DynamicFormItemType.PROMPT_EDITOR: { // Guard: field.value may be undefined when the form resets or // initialValues haven't propagated yet. Fall back to a default diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 62f1dbf0a..9a1fd4371 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -56,6 +56,13 @@ export function getDefaultValues( return acc; } acc[item.name] = item.default; + if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) { + acc['enable-all-tools'] = true; + } + if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) { + acc['mcp-resources'] = []; + acc['mcp-resource-agent-read-enabled'] = true; + } return acc; }, diff --git a/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx new file mode 100644 index 000000000..9b138c140 --- /dev/null +++ b/web/src/app/home/components/dynamic-form/ToolResourceSelectors.tsx @@ -0,0 +1,1074 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { backendClient } from '@/app/infra/http'; +import { + KnowledgeBase, + MCPResource, + MCPServer, + PluginTool, +} from '@/app/infra/entities/api'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + CircleHelp, + Database, + FileText, + Plus, + Server, + Wrench, + X, +} from 'lucide-react'; + +type ToolSource = NonNullable; + +type BoundMCPResource = { + server_uuid?: string; + server_name?: string; + uri: string; + mode?: string; + enabled?: boolean; + max_bytes?: number; + max_tokens?: number; +}; + +type PipelineExtensions = Awaited< + ReturnType +>; +type AvailablePlugin = PipelineExtensions['available_plugins'][number]; + +type ToolProviderGroup = { + key: string; + name: string; + tools: PluginTool[]; +}; + +type ToolSourceGroup = { + key: ToolSource | string; + label: string; + groups: ToolProviderGroup[]; + total: number; +}; + +type MCPToolOwner = { + serverId?: string; + serverName: string; +}; + +type PluginToolOwner = { + pluginId: string; +}; + +type BoundPlugin = PipelineExtensions['bound_plugins'][number]; + +const BUILTIN_TOOL_NAMES = new Set([ + 'exec', + 'read', + 'write', + 'edit', + 'glob', + 'grep', +]); +const TOOL_SOURCE_ORDER = ['plugin', 'mcp', 'skill', 'builtin']; + +function getMCPResourceKey(server: MCPServer, uri: string) { + return `${server.uuid || server.name}:${uri}`; +} + +function isSameMCPResource( + resource: BoundMCPResource, + server: MCPServer, + uri: string, +) { + return ( + (resource.server_uuid === server.uuid || + (!resource.server_uuid && resource.server_name === server.name)) && + resource.uri === uri + ); +} + +function getBoundPluginId(plugin: BoundPlugin) { + return `${plugin.author || ''}/${plugin.name}`; +} + +function InfoTooltip({ label }: { label: string }) { + return ( + + + + + + {label} + + + ); +} + +function normalizeToolSource(tool: PluginTool): ToolSource | string { + if (tool.source) { + return tool.source; + } + if (BUILTIN_TOOL_NAMES.has(tool.name)) { + return 'builtin'; + } + if (tool.name.startsWith('langbot_mcp_')) { + return 'mcp'; + } + return 'plugin'; +} + +function hasReliableSourceMetadata(tool: PluginTool) { + if (!tool.source) { + return false; + } + if (tool.source === 'plugin' || tool.source === 'mcp') { + return Boolean(tool.source_id || tool.source_name); + } + return true; +} + +function buildToolSourceGroups( + tools: PluginTool[], + sourceLabels: Record, +) { + const sourceGroups = new Map>(); + + for (const tool of tools) { + const source = normalizeToolSource(tool); + const providerName = + tool.source_name || + (source === 'builtin' ? 'LangBot' : sourceLabels[source] || source); + const providerKey = `${source}:${tool.source_id || providerName}`; + + if (!sourceGroups.has(source)) { + sourceGroups.set(source, new Map()); + } + + const providerGroups = sourceGroups.get(source)!; + if (!providerGroups.has(providerKey)) { + providerGroups.set(providerKey, { + key: providerKey, + name: providerName, + tools: [], + }); + } + providerGroups.get(providerKey)!.tools.push(tool); + } + + return Array.from(sourceGroups.entries()) + .sort(([left], [right]) => { + const leftIndex = TOOL_SOURCE_ORDER.indexOf(left); + const rightIndex = TOOL_SOURCE_ORDER.indexOf(right); + return ( + (leftIndex === -1 ? TOOL_SOURCE_ORDER.length : leftIndex) - + (rightIndex === -1 ? TOOL_SOURCE_ORDER.length : rightIndex) + ); + }) + .map( + ([source, providerGroups]): ToolSourceGroup => ({ + key: source, + label: sourceLabels[source] || source, + groups: Array.from(providerGroups.values()).sort((left, right) => + left.name.localeCompare(right.name), + ), + total: Array.from(providerGroups.values()).reduce( + (count, group) => count + group.tools.length, + 0, + ), + }), + ); +} + +function buildMCPToolOwners(servers: MCPServer[]) { + const owners = new Map(); + const ambiguousNames = new Set(); + + for (const server of servers) { + for (const tool of server.runtime_info?.tools || []) { + if (!tool.name) { + continue; + } + const owner = { + serverId: server.uuid, + serverName: server.name, + }; + + if (owners.has(tool.name)) { + ambiguousNames.add(tool.name); + continue; + } + owners.set(tool.name, owner); + } + } + + for (const name of ambiguousNames) { + owners.delete(name); + } + + return owners; +} + +function buildPluginToolOwners(plugins: AvailablePlugin[]) { + const owners = new Map(); + const ambiguousNames = new Set(); + + for (const plugin of plugins) { + const metadata = plugin.manifest?.manifest?.metadata; + const pluginName = metadata?.name; + if (!pluginName) { + continue; + } + + const pluginId = metadata.author + ? `${metadata.author}/${pluginName}` + : pluginName; + + for (const component of plugin.components || []) { + const manifest = component.manifest?.manifest; + if (manifest?.kind !== 'Tool') { + continue; + } + + const toolName = manifest.metadata?.name; + if (!toolName) { + continue; + } + + if (owners.has(toolName)) { + ambiguousNames.add(toolName); + continue; + } + owners.set(toolName, { pluginId }); + } + } + + for (const name of ambiguousNames) { + owners.delete(name); + } + + return owners; +} + +function annotateMCPToolSource( + tool: PluginTool, + owner?: MCPToolOwner, +): PluginTool { + if (!owner || hasReliableSourceMetadata(tool)) { + return tool; + } + return { + ...tool, + source: 'mcp', + source_name: owner.serverName, + source_id: owner.serverId, + }; +} + +function annotatePluginToolSource( + tool: PluginTool, + owner?: PluginToolOwner, +): PluginTool { + if (!owner || hasReliableSourceMetadata(tool)) { + return tool; + } + return { + ...tool, + source: 'plugin', + source_name: owner.pluginId, + source_id: owner.pluginId, + }; +} + +function annotateToolSource( + tool: PluginTool, + pluginOwner?: PluginToolOwner, + mcpOwner?: MCPToolOwner, +): PluginTool { + if (hasReliableSourceMetadata(tool)) { + return tool; + } + + if (tool.source === 'plugin') { + return annotatePluginToolSource(tool, pluginOwner); + } + if (tool.source === 'mcp') { + return annotateMCPToolSource(tool, mcpOwner); + } + if (mcpOwner && !pluginOwner) { + return annotateMCPToolSource(tool, mcpOwner); + } + if (pluginOwner && !mcpOwner) { + return annotatePluginToolSource(tool, pluginOwner); + } + + return tool; +} + +export default function ToolResourceSelectors({ + pipelineId, + value, + onChange, + mode = 'all', +}: { + pipelineId?: string; + value: Record; + onChange: (patch: Record) => void; + mode?: 'tools' | 'resources' | 'all'; +}) { + const { t } = useTranslation(); + const [tools, setTools] = useState([]); + const [knowledgeBases, setKnowledgeBases] = useState([]); + const [extensions, setExtensions] = useState(null); + const [toolsDialogOpen, setToolsDialogOpen] = useState(false); + const [kbDialogOpen, setKbDialogOpen] = useState(false); + const [tempSelectedToolNames, setTempSelectedToolNames] = useState( + [], + ); + const [tempSelectedKBIds, setTempSelectedKBIds] = useState([]); + + useEffect(() => { + if (mode !== 'resources') { + backendClient.getTools(pipelineId).then((resp) => setTools(resp.tools)); + } + if (mode !== 'tools') { + backendClient + .getKnowledgeBases() + .then((resp) => setKnowledgeBases(resp.bases)); + } + if (pipelineId) { + backendClient + .getPipelineExtensions(pipelineId) + .then((resp) => setExtensions(resp)); + } + }, [mode, pipelineId]); + + const enableAllTools = value['enable-all-tools'] !== false; + const selectedToolNames = Array.isArray(value.tools) ? value.tools : []; + const selectedKBIds = Array.isArray(value['knowledge-bases']) + ? value['knowledge-bases'] + : []; + const selectedMCPResources: BoundMCPResource[] = Array.isArray( + value['mcp-resources'], + ) + ? value['mcp-resources'] + : extensions?.bound_mcp_resources || []; + const mcpResourceReadEnabled = + typeof value['mcp-resource-agent-read-enabled'] === 'boolean' + ? value['mcp-resource-agent-read-enabled'] + : (extensions?.mcp_resource_agent_read_enabled ?? true); + + const scopedMCPServers = useMemo(() => { + if (!extensions) return []; + const boundServerIds = new Set(extensions.bound_mcp_servers || []); + return extensions.enable_all_mcp_servers + ? extensions.available_mcp_servers + : extensions.available_mcp_servers.filter((server) => + boundServerIds.has(server.uuid || ''), + ); + }, [extensions]); + + const scopedMCPServerIds = useMemo( + () => + new Set( + scopedMCPServers + .map((server) => server.uuid) + .filter((uuid): uuid is string => !!uuid), + ), + [scopedMCPServers], + ); + + const scopedMCPServerNames = useMemo( + () => new Set(scopedMCPServers.map((server) => server.name)), + [scopedMCPServers], + ); + + const mcpToolOwners = useMemo( + () => buildMCPToolOwners(scopedMCPServers), + [scopedMCPServers], + ); + + const pluginToolOwners = useMemo( + () => buildPluginToolOwners(extensions?.available_plugins || []), + [extensions], + ); + + const scopedPluginIds = useMemo( + () => + new Set( + extensions?.enable_all_plugins + ? (extensions.available_plugins || []).map((plugin) => { + const metadata = plugin.manifest?.manifest?.metadata; + return metadata?.name + ? `${metadata.author || ''}/${metadata.name}` + : ''; + }) + : (extensions?.bound_plugins || []).map(getBoundPluginId), + ), + [extensions], + ); + + const availableTools = useMemo( + () => + tools + .map((tool) => + annotateToolSource( + tool, + pluginToolOwners.get(tool.name), + mcpToolOwners.get(tool.name), + ), + ) + .filter((tool) => { + const source = normalizeToolSource(tool); + + if (source === 'plugin') { + if (!extensions) { + return false; + } + if (extensions.enable_all_plugins) { + return true; + } + return ( + (tool.source_id && scopedPluginIds.has(tool.source_id)) || + (tool.source_name && scopedPluginIds.has(tool.source_name)) + ); + } + + if (source === 'mcp') { + if (!extensions) { + return false; + } + if (extensions.enable_all_mcp_servers) { + return true; + } + return ( + (tool.source_id && scopedMCPServerIds.has(tool.source_id)) || + (tool.source_name && scopedMCPServerNames.has(tool.source_name)) + ); + } + + return true; + }), + [ + extensions, + mcpToolOwners, + pluginToolOwners, + scopedMCPServerIds, + scopedMCPServerNames, + scopedPluginIds, + tools, + ], + ); + + const availableToolNames = useMemo( + () => new Set(availableTools.map((tool) => tool.name)), + [availableTools], + ); + + const resourceServers = useMemo(() => { + return scopedMCPServers.filter( + (server) => + server.runtime_info?.status === 'connected' && + (server.runtime_info.resources || []).length > 0, + ); + }, [scopedMCPServers]); + + const availableMCPResourceKeys = useMemo(() => { + const keys = new Set(); + for (const server of resourceServers) { + for (const resource of server.runtime_info?.resources || []) { + keys.add(getMCPResourceKey(server, resource.uri)); + } + } + return keys; + }, [resourceServers]); + + const scopedSelectedMCPResources = selectedMCPResources.filter((resource) => { + const server = scopedMCPServers.find( + (item) => + item.uuid === resource.server_uuid || + (!resource.server_uuid && item.name === resource.server_name), + ); + return server + ? availableMCPResourceKeys.has(getMCPResourceKey(server, resource.uri)) + : false; + }); + + const selectedTools = selectedToolNames + .map((name: string) => availableTools.find((tool) => tool.name === name)) + .filter((tool): tool is PluginTool => !!tool); + + const selectedKnowledgeBases = selectedKBIds + .map((kbId: string) => knowledgeBases.find((base) => base.uuid === kbId)) + .filter((base): base is KnowledgeBase => !!base); + + const sourceLabels = useMemo>( + () => ({ + builtin: t('pipelines.localAgent.builtinTools'), + plugin: t('pipelines.localAgent.pluginTools'), + mcp: t('pipelines.localAgent.mcpTools'), + skill: t('pipelines.localAgent.skillTools'), + }), + [t], + ); + + const availableToolGroups = useMemo( + () => buildToolSourceGroups(availableTools, sourceLabels), + [availableTools, sourceLabels], + ); + const selectedToolGroups = useMemo( + () => buildToolSourceGroups(selectedTools, sourceLabels), + [selectedTools, sourceLabels], + ); + + const handleToggleToolMode = (checked: boolean) => { + onChange({ 'enable-all-tools': checked }); + }; + + const handleConfirmTools = () => { + onChange({ + tools: tempSelectedToolNames.filter((name) => + availableToolNames.has(name), + ), + }); + setToolsDialogOpen(false); + }; + + const handleConfirmKnowledgeBases = () => { + onChange({ 'knowledge-bases': tempSelectedKBIds }); + setKbDialogOpen(false); + }; + + const handleToggleMCPResource = ( + server: MCPServer, + resource: MCPResource, + checked: boolean, + ) => { + const next = checked + ? [ + ...scopedSelectedMCPResources.filter( + (item) => !isSameMCPResource(item, server, resource.uri), + ), + { + server_uuid: server.uuid, + server_name: server.name, + uri: resource.uri, + mode: 'pinned', + enabled: true, + }, + ] + : scopedSelectedMCPResources.filter( + (item) => !isSameMCPResource(item, server, resource.uri), + ); + onChange({ 'mcp-resources': next }); + }; + + const isMCPResourceSelected = (server: MCPServer, uri: string) => + scopedSelectedMCPResources.some( + (resource) => + isSameMCPResource(resource, server, uri) && resource.enabled !== false, + ); + + return ( +
+ {mode !== 'resources' && ( +
+
+
+
+

+ {t('pipelines.localAgent.toolsTitle')} +

+ +
+

+ {t('pipelines.localAgent.toolsDescription')} +

+
+
+ + +
+
+ + {enableAllTools ? ( +
+

+ {t('pipelines.localAgent.allToolsEnabled')} +

+
+ ) : selectedTools.length === 0 ? ( +
+

+ {t('pipelines.localAgent.noToolsSelected')} +

+
+ ) : ( +
+ {selectedToolGroups.map((sourceGroup) => ( +
+
+ {sourceGroup.label} + + {sourceGroup.total} + +
+ {sourceGroup.groups.map((providerGroup) => ( +
+
+ + + {providerGroup.name} + + + {providerGroup.tools.length} + +
+
+ {providerGroup.tools.map((tool) => ( +
+
+
+ {tool.name} +
+ {tool.human_desc && ( +
+ {tool.human_desc} +
+ )} +
+ +
+ ))} +
+
+ ))} +
+ ))} +
+ )} + + +
+ )} + + {mode !== 'tools' && ( +
+
+

+ {t('pipelines.localAgent.resourcesTitle')} +

+

+ {t('pipelines.localAgent.resourcesDescription')} +

+
+ +
+
+
+ + + {t('pipelines.localAgent.knowledgeBases')} + +
+ +
+ {selectedKnowledgeBases.length === 0 ? ( +
+

+ {t('knowledge.noKnowledgeBaseSelected')} +

+
+ ) : ( +
+ {selectedKnowledgeBases.map((base) => ( +
+
+
+ {base.emoji && {base.emoji}} + {base.name} + {base.knowledge_engine?.name && ( + + {extractI18nObject(base.knowledge_engine.name)} + + )} +
+ {base.description && ( +
+ {base.description} +
+ )} +
+ +
+ ))} +
+ )} +
+ +
+
+
+ + + {t('pipelines.localAgent.mcpResources')} + + +
+
+ + + + onChange({ 'mcp-resource-agent-read-enabled': checked }) + } + /> +
+
+ + {resourceServers.length === 0 ? ( +
+

+ {t('pipelines.localAgent.noMCPResourcesAvailable')} +

+
+ ) : ( +
+ {resourceServers.map((server) => ( +
+
+ + {server.name} + + {server.runtime_info?.resources?.length || 0} + +
+
+ {(server.runtime_info?.resources || []).map( + (resource) => ( + + ), + )} +
+
+ ))} +
+ )} +
+
+ )} + + {mode !== 'resources' && ( + + + + {t('pipelines.localAgent.selectTools')} + +
+ {availableToolGroups.map((sourceGroup) => { + return ( +
+
+ + {sourceGroup.label} + + {sourceGroup.key === 'mcp' && ( + + )} + {sourceGroup.key === 'skill' && ( + + )} + + {sourceGroup.total} + +
+
+ {sourceGroup.groups.map((providerGroup) => ( +
+
+ + + {providerGroup.name} + + + {providerGroup.tools.length} + +
+
+ {providerGroup.tools.map((tool) => { + const selected = tempSelectedToolNames.includes( + tool.name, + ); + return ( +
+ setTempSelectedToolNames((prev) => + prev.includes(tool.name) + ? prev.filter( + (name) => name !== tool.name, + ) + : [...prev, tool.name], + ) + } + > + +
+
+ {tool.name} +
+ {tool.human_desc && ( +
+ {tool.human_desc} +
+ )} +
+
+ ); + })} +
+
+ ))} +
+
+ ); + })} + {availableToolGroups.length === 0 && ( +
+

+ {t('pipelines.localAgent.noToolsSelected')} +

+
+ )} +
+ + + + +
+
+ )} + + {mode !== 'tools' && ( + + + + + {t('pipelines.localAgent.selectKnowledgeBases')} + + +
+ {knowledgeBases.map((base) => { + const kbId = base.uuid || ''; + const selected = tempSelectedKBIds.includes(kbId); + return ( +
+ setTempSelectedKBIds((prev) => + prev.includes(kbId) + ? prev.filter((id) => id !== kbId) + : [...prev, kbId], + ) + } + > + + +
+
+ {base.emoji && {base.emoji}} + {base.name} +
+ {base.description && ( +
+ {base.description} +
+ )} +
+
+ ); + })} + {knowledgeBases.length === 0 && ( +
+

+ {t('knowledge.noKnowledgeBaseSelected')} +

+
+ )} +
+ + + + +
+
+ )} +
+ ); +} diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index e92d635f8..a5a057d61 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -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,121 @@ 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(null); + const [resourceContent, setResourceContent] = + React.useState(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, + 65536, + ); + if (resp.contents && resp.contents.length > 0) { + setResourceContent(resp.contents[0]); + } + } catch { + setResourceContent(null); + } finally { + setLoadingContent(false); + } + }; + + return ( +
+ {resources.map((resource, index) => ( + + handleToggleResource(resource.uri)} + > +
+ {resource.name} + {resource.mime_type && ( + + {resource.mime_type} + + )} +
+ {resource.description && ( + + {resource.description} + + )} +
+ {resource.uri} +
+
+ {expandedUri === resource.uri && ( + + {loadingContent ? ( +
+ {t('mcp.loading')} +
+ ) : resourceContent?.type === 'text' && resourceContent.text ? ( +
+
+                    {resourceContent.text}
+                  
+ {resourceContent.truncated && ( +
+ {t('mcp.resourceTruncated')} +
+ )} +
+ ) : resourceContent?.type === 'blob' ? ( +
+ {resourceContent.binary_omitted + ? t('mcp.resourceBinaryOmitted') + : t('mcp.resourceBinaryContent')} +
+ ) : ( +
+ {t('mcp.resourceReadFailed')} +
+ )} +
+ )} +
+ ))} +
+ ); +} + +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 +369,9 @@ function RuntimePanel({ if (!runtimeInfo) { return (
- {t('mcp.noToolsFound')} + {content === 'resources' + ? t('mcp.noResourcesFound') + : t('mcp.noToolsFound')}
); } @@ -267,6 +379,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 (
@@ -276,11 +397,26 @@ function RuntimePanel({
)} - {isConnected && tools.length > 0 && } + {isConnected && showTools && tools.length > 0 && ( + + )} - {isConnected && tools.length === 0 && ( + {isConnected && showResources && resources.length > 0 && ( +
+ {content === 'all' && ( +
+ {t('mcp.resourceCount', { count: resources.length })} +
+ )} + +
+ )} + + {isConnected && empty && (
- {t('mcp.noToolsFound')} + {content === 'resources' + ? t('mcp.noResourcesFound') + : t('mcp.noToolsFound')}
)} @@ -732,6 +868,8 @@ const MCPForm = forwardRef(function MCPForm( error_message: errorMsg, tool_count: 0, tools: [], + resource_count: 0, + resources: [], }); } else { if (isEditMode) { @@ -1026,20 +1164,29 @@ const MCPForm = forwardRef(function MCPForm( ); const runtimePanel = ( - + ); // 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 ? ( @@ -1050,6 +1197,9 @@ const MCPForm = forwardRef(function MCPForm( {toolsTabLabel} + + {resourcesTabLabel} + @@ -1058,7 +1208,25 @@ const MCPForm = forwardRef(function MCPForm( value="tools" className="mt-4 min-h-0 flex-1 overflow-y-auto" > - {runtimePanel} + + + + ) : ( diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index a96101798..a26779109 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -12,16 +12,40 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Checkbox } from '@/components/ui/checkbox'; -import { Plus, X, Server, Wrench, Sparkles } from 'lucide-react'; +import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { Plugin } from '@/app/infra/entities/plugin'; import { MCPServer, Skill } from '@/app/infra/entities/api'; import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList'; import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice'; import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus'; +function InfoTooltip({ label }: { label: string }) { + return ( + + + + + + {label} + + + ); +} + export default function PipelineExtension({ pipelineId, }: { @@ -418,9 +442,14 @@ export default function PipelineExtension({ {/* MCP Servers Section */}
-

- {t('pipelines.extensions.mcpServersTitle')} -

+
+

+ {t('pipelines.extensions.mcpServersTitle')} +

+ +
+ = (form.watch(formName) as Record)?.[stage.name] || {}; const effectiveInitialValues = - stage.name === 'local-agent' && boxScopeForced + isLocalAgentStage && boxScopeForced ? { ...stageInitialValues, 'box-session-id-template': forcedBoxTemplate, } : stageInitialValues; + const emitStageValues = (values: object) => { + if (!isLocalAgentStage) { + handleDynamicFormEmit(formName, stage.name, values); + return; + } + + const latestStageValues = + ((form.getValues(formName) as Record) || {})[stage.name] || + {}; + handleDynamicFormEmit(formName, stage.name, { + ...latestStageValues, + ...values, + }); + }; return ( @@ -467,9 +482,7 @@ export default function PipelineFormComponent({ { - handleDynamicFormEmit(formName, stage.name, values); - }} + onSubmit={emitStageValues} systemContext={stageSystemContext} /> diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 61cb33acd..a268096d2 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -563,6 +563,11 @@ export interface MCPServerRuntimeInfo { * server runs inside Box. Absent when Box is unavailable. */ box_session_id?: string; box_enabled?: boolean; + resource_count: number; + resources: MCPResource[]; + resource_template_count?: number; + resource_templates?: MCPResourceTemplate[]; + resource_capabilities?: Record; } export type MCPServer = @@ -617,11 +622,67 @@ export interface MCPTool { parameters?: object; } +export interface MCPResource { + uri: string; + name: string; + title?: string; + description: string; + mime_type: string; + size?: number; + icons?: object[]; + annotations?: Record; + _meta?: Record; +} + +export interface MCPResourceTemplate { + uri_template: string; + name: string; + title?: string; + description: string; + mime_type: string; + icons?: object[]; + annotations?: Record; + _meta?: Record; +} + +export interface MCPResourceContent { + uri: string; + mime_type: string; + type: 'text' | 'blob'; + text?: string; + blob?: string | null; + bytes?: number; + truncated?: boolean; + binary_omitted?: boolean; + _meta?: Record; +} + +export interface ApiRespMCPResources { + resources: MCPResource[]; + resource_templates?: MCPResourceTemplate[]; + resource_capabilities?: Record; +} + +export interface ApiRespMCPResourceContents { + contents: MCPResourceContent[]; + server_name?: string; + server_uuid?: string; + uri?: string; + source?: string; + bytes?: number; + truncated?: boolean; + cache_hit?: boolean; + warnings?: string[]; +} + export interface PluginTool { name: string; description: string; human_desc: string; parameters: object; + source?: 'builtin' | 'plugin' | 'mcp' | 'skill'; + source_name?: string; + source_id?: string; } export interface ApiRespTools { diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 44fed3acf..a97bd9992 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -67,6 +67,8 @@ export enum DynamicFormItemType { PLUGIN_SELECTOR = 'plugin-selector', BOT_SELECTOR = 'bot-selector', TOOLS_SELECTOR = 'tools-selector', + RICH_TOOLS_SELECTOR = 'rich-tools-selector', + RESOURCES_SELECTOR = 'resources-selector', WEBHOOK_URL = 'webhook-url', EMBED_CODE = 'embed-code', QR_CODE_LOGIN = 'qr-code-login', diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 78057aa97..61995cffb 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -40,6 +40,8 @@ import { BoxSessionInfo, ApiRespMCPServers, ApiRespMCPServer, + ApiRespMCPResources, + ApiRespMCPResourceContents, MCPServer, ApiRespModelProviders, ApiRespModelProvider, @@ -269,10 +271,20 @@ export class BackendClient extends BaseHttpClient { enable_all_plugins: boolean; enable_all_mcp_servers: boolean; enable_all_skills: boolean; + mcp_resource_agent_read_enabled: boolean; bound_plugins: Array<{ author: string; name: string }>; available_plugins: Plugin[]; bound_mcp_servers: string[]; available_mcp_servers: MCPServer[]; + bound_mcp_resources: Array<{ + server_uuid?: string; + server_name?: string; + uri: string; + mode?: string; + enabled?: boolean; + max_bytes?: number; + max_tokens?: number; + }>; bound_skills: string[]; available_skills: Skill[]; }> { @@ -287,15 +299,32 @@ export class BackendClient extends BaseHttpClient { enable_all_mcp_servers: boolean = true, bound_skills: string[] = [], enable_all_skills: boolean = true, + bound_mcp_resources?: Array<{ + server_uuid?: string; + server_name?: string; + uri: string; + mode?: string; + enabled?: boolean; + max_bytes?: number; + max_tokens?: number; + }>, + mcp_resource_agent_read_enabled?: boolean, ): Promise { - return this.put(`/api/v1/pipelines/${uuid}/extensions`, { + const payload: Record = { bound_plugins, bound_mcp_servers, enable_all_plugins, enable_all_mcp_servers, bound_skills, enable_all_skills, - }); + }; + if (bound_mcp_resources !== undefined) { + payload.bound_mcp_resources = bound_mcp_resources; + } + if (mcp_resource_agent_read_enabled !== undefined) { + payload.mcp_resource_agent_read_enabled = mcp_resource_agent_read_enabled; + } + return this.put(`/api/v1/pipelines/${uuid}/extensions`, payload); } // ============ WebSocket Chat API ============ @@ -865,8 +894,11 @@ export class BackendClient extends BaseHttpClient { // ========== Tools ========== - public getTools(): Promise { - return this.get('/api/v1/tools'); + public getTools(pipelineId?: string): Promise { + return this.get( + '/api/v1/tools', + pipelineId ? { pipeline_uuid: pipelineId } : undefined, + ); } public getToolDetail(toolName: string): Promise { @@ -926,6 +958,28 @@ export class BackendClient extends BaseHttpClient { return this.post('/api/v1/mcp/servers', { source }); } + public getMCPServerResources( + serverName: string, + ): Promise { + return this.get( + `/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources`, + ); + } + + public readMCPServerResource( + serverName: string, + uri: string, + maxBytes?: number, + ): Promise { + return this.post( + `/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources/read`, + { + uri, + max_bytes: maxBytes, + }, + ); + } + // ============ System API ============ public getSystemInfo(): Promise { return this.get('/api/v1/system/info'); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 44ad3769b..9f95f650f 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -824,7 +824,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', @@ -844,6 +846,11 @@ const enUS = { toolCount: 'Tools: {{count}}', parameterCount: 'Parameters: {{count}}', noParameters: 'No parameters', + resourceCount: 'Resources: {{count}}', + resourceBinaryContent: 'Binary content (cannot be displayed)', + resourceBinaryOmitted: 'Binary content omitted by resource safety policy', + resourceTruncated: 'Content truncated by byte or token limits', + resourceReadFailed: 'Failed to read resource content', statusConnected: 'Connected', statusDisconnected: 'Disconnected', statusError: 'Connection Error', @@ -935,9 +942,12 @@ const enUS = { selectPlugins: 'Select Plugins', pluginsTitle: 'Plugins', mcpServersTitle: 'MCP Servers', + mcpResourcesTitle: 'MCP Resources', noMCPServersSelected: 'No MCP servers selected', + noMCPResourcesAvailable: 'No MCP resources available', addMCPServer: 'Add MCP Server', selectMCPServers: 'Select MCP Servers', + enableMCPResourceAgentRead: 'Agent read', toolCount: '{{count}} tools', noPluginsInstalled: 'No installed plugins', noMCPServersConfigured: 'No configured MCP servers', @@ -953,6 +963,42 @@ const enUS = { addSkill: 'Add Skill', selectSkills: 'Select Skills', noSkillsAvailable: 'No skills available', + mcpServersScopeTooltip: + 'This only controls which MCP servers are bound to the pipeline. Choose exact MCP tools and resources in AI Feature > Local Agent.', + enableAllMCPServersTooltip: + 'When enabled, all configured and enabled MCP servers become candidates for MCP tools and resources in AI Feature.', + }, + localAgent: { + toolsTitle: 'Tools', + toolsDescription: + 'Select plugin, MCP, skill, and built-in tools available to this Local Agent.', + toolsScopeTooltip: + 'MCP tools only come from MCP servers bound in Extensions. Bind another MCP server there to make its tools selectable here.', + enableAllTools: 'Enable all tools', + allToolsEnabled: 'All available tools are enabled', + noToolsSelected: 'No tools selected', + editTools: 'Edit tools', + builtinTools: 'Built-in tools', + pluginTools: 'Plugin tools', + skillTools: 'Skill tools', + mcpTools: 'MCP tools', + mcpToolsScopeTooltip: + 'Only tools from MCP servers currently allowed in Extensions are shown here.', + skillToolsScopeTooltip: + 'Skill tools are available when LangBot skill service and the Box sandbox backend are ready. They let the agent activate or register skills.', + selectTools: 'Select tools', + resourcesTitle: 'Resources', + resourcesDescription: + 'Select MCP resources and knowledge bases available to this Local Agent.', + knowledgeBases: 'Knowledge bases', + mcpResources: 'MCP resources', + mcpResourcesScopeTooltip: + 'Only resources exposed by MCP servers currently allowed in Extensions are shown here.', + enableMCPResourceRead: 'Allow model to read MCP resources', + mcpResourceReadTooltip: + 'When disabled, selected MCP resources are not injected into model context.', + noMCPResourcesAvailable: 'No MCP resources available', + selectKnowledgeBases: 'Select knowledge bases', }, debugDialog: { title: 'Pipeline Chat', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index f30e333ed..836d9f5cf 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -838,7 +838,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', @@ -858,6 +860,12 @@ const esES = { toolCount: 'Herramientas: {{count}}', parameterCount: 'Parámetros: {{count}}', noParameters: 'Sin parámetros', + resourceCount: 'Recursos: {{count}}', + resourceBinaryContent: 'Contenido binario (no se puede mostrar)', + resourceBinaryOmitted: + 'Contenido binario omitido por la política de seguridad de recursos', + resourceTruncated: 'Contenido truncado por límites de bytes o tokens', + resourceReadFailed: 'Error al leer el contenido del recurso', statusConnected: 'Conectado', statusDisconnected: 'Desconectado', statusError: 'Error de conexión', @@ -952,9 +960,12 @@ const esES = { selectPlugins: 'Seleccionar plugins', pluginsTitle: 'Plugins', mcpServersTitle: 'Servidores MCP', + mcpResourcesTitle: 'Recursos MCP', noMCPServersSelected: 'No hay servidores MCP seleccionados', + noMCPResourcesAvailable: 'No hay recursos MCP disponibles', addMCPServer: 'Añadir servidor MCP', selectMCPServers: 'Seleccionar servidores MCP', + enableMCPResourceAgentRead: 'Permitir lectura del modelo', toolCount: '{{count}} herramientas', noPluginsInstalled: 'No hay plugins instalados', noMCPServersConfigured: 'No hay servidores MCP configurados', @@ -970,6 +981,40 @@ const esES = { addSkill: 'Añadir skill', selectSkills: 'Seleccionar skills', noSkillsAvailable: 'No hay skills disponibles', + mcpServersScopeTooltip: + 'Aquí solo se controla qué servidores MCP se vinculan al Pipeline. Las herramientas y recursos MCP concretos se eligen en AI Feature > Local Agent.', + enableAllMCPServersTooltip: + 'Al activarlo, todos los servidores MCP configurados y habilitados serán candidatos para herramientas y recursos MCP en AI Feature.', + }, + localAgent: { + toolsTitle: 'Herramientas', + toolsDescription: + 'Selecciona las herramientas de plugins, MCP e integradas disponibles para este Local Agent.', + toolsScopeTooltip: + 'Las herramientas MCP solo provienen de servidores MCP vinculados en Extensiones. Vincula allí otro servidor para poder seleccionarlo aquí.', + enableAllTools: 'Activar todas las herramientas', + allToolsEnabled: 'Todas las herramientas disponibles están activadas', + noToolsSelected: 'No hay herramientas seleccionadas', + editTools: 'Editar herramientas', + builtinTools: 'Herramientas integradas', + pluginTools: 'Herramientas de plugin', + skillTools: 'Herramientas de skill', + mcpTools: 'Herramientas MCP', + mcpToolsScopeTooltip: + 'Aquí solo se muestran herramientas de servidores MCP permitidos actualmente en Extensiones.', + selectTools: 'Seleccionar herramientas', + resourcesTitle: 'Recursos', + resourcesDescription: + 'Selecciona los recursos MCP y bases de conocimiento disponibles para este Local Agent.', + knowledgeBases: 'Bases de conocimiento', + mcpResources: 'Recursos MCP', + mcpResourcesScopeTooltip: + 'Aquí solo se muestran recursos expuestos por servidores MCP permitidos actualmente en Extensiones.', + enableMCPResourceRead: 'Permitir que el modelo lea recursos MCP', + mcpResourceReadTooltip: + 'Si se desactiva, los recursos MCP seleccionados no se inyectarán en el contexto del modelo.', + noMCPResourcesAvailable: 'No hay recursos MCP disponibles', + selectKnowledgeBases: 'Seleccionar bases de conocimiento', }, debugDialog: { title: 'Chat del Pipeline', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f139c7bbf..5c8d92de0 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -830,7 +830,9 @@ const jaJP = { toolsFound: '個のツール', unknownError: '不明なエラー', noToolsFound: 'ツールが見つかりません', + noResourcesFound: 'リソースが見つかりません', tabTools: 'ツール', + tabResources: 'リソース', tabDocs: 'ドキュメント', noReadme: 'ドキュメントがありません', parseResultFailed: 'テスト結果の解析に失敗しました', @@ -850,6 +852,12 @@ const jaJP = { toolCount: 'ツール:{{count}}', parameterCount: 'パラメータ:{{count}}', noParameters: 'パラメータなし', + resourceCount: 'リソース:{{count}}', + resourceBinaryContent: 'バイナリコンテンツ(表示できません)', + resourceBinaryOmitted: + 'リソース安全ポリシーによりバイナリコンテンツを省略しました', + resourceTruncated: 'バイトまたはトークンの上限により内容を切り詰めました', + resourceReadFailed: 'リソースの読み込みに失敗しました', statusConnected: '接続済み', statusDisconnected: '未接続', statusError: '接続エラー', @@ -939,9 +947,12 @@ const jaJP = { selectPlugins: 'プラグインを選択', pluginsTitle: 'プラグイン', mcpServersTitle: 'MCPサーバー', + mcpResourcesTitle: 'MCPリソース', noMCPServersSelected: 'MCPサーバーが選択されていません', + noMCPResourcesAvailable: '利用可能なMCPリソースがありません', addMCPServer: 'MCPサーバーを追加', selectMCPServers: 'MCPサーバーを選択', + enableMCPResourceAgentRead: 'モデルの読み取りを許可', toolCount: '{{count}}個のツール', noPluginsInstalled: 'インストールされているプラグインがありません', noMCPServersConfigured: '設定されているMCPサーバーがありません', @@ -957,6 +968,40 @@ const jaJP = { addSkill: 'スキルを追加', selectSkills: 'スキルを選択', noSkillsAvailable: '利用可能なスキルがありません', + mcpServersScopeTooltip: + 'ここでは、このパイプラインに紐付ける MCP サーバーだけを管理します。個別の MCP ツールとリソースは AI 機能の Local Agent で選択します。', + enableAllMCPServersTooltip: + '有効にすると、設定済みで有効なすべての MCP サーバーが AI 機能の MCP ツールとリソース候補になります。', + }, + localAgent: { + toolsTitle: 'ツール', + toolsDescription: + 'この Local Agent が使用できるプラグイン、MCP、組み込みツールを選択します。', + toolsScopeTooltip: + 'MCP ツールは拡張機能で紐付けられた MCP サーバーからのみ表示されます。追加するには先に拡張機能でサーバーを紐付けてください。', + enableAllTools: 'すべてのツールを有効化', + allToolsEnabled: '利用可能なすべてのツールが有効です', + noToolsSelected: 'ツールが選択されていません', + editTools: 'ツールを編集', + builtinTools: '組み込みツール', + pluginTools: 'プラグインツール', + skillTools: 'スキルツール', + mcpTools: 'MCP ツール', + mcpToolsScopeTooltip: + '拡張機能で現在許可されている MCP サーバーのツールだけが表示されます。', + selectTools: 'ツールを選択', + resourcesTitle: 'リソース', + resourcesDescription: + 'この Local Agent が読み取れる MCP リソースとナレッジベースを選択します。', + knowledgeBases: 'ナレッジベース', + mcpResources: 'MCP リソース', + mcpResourcesScopeTooltip: + '拡張機能で現在許可されている MCP サーバーのリソースだけが表示されます。', + enableMCPResourceRead: 'モデルによる MCP リソース読み取りを許可', + mcpResourceReadTooltip: + '無効にすると、選択済みの MCP リソースもモデルコンテキストに注入されません。', + noMCPResourcesAvailable: '利用可能な MCP リソースがありません', + selectKnowledgeBases: 'ナレッジベースを選択', }, debugDialog: { title: 'パイプラインのチャット', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 1ecd8fce7..7b8c2c64e 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -835,7 +835,9 @@ const ruRU = { toolsFound: 'инструментов', unknownError: 'Неизвестная ошибка', noToolsFound: 'Инструменты не найдены', + noResourcesFound: 'Ресурсы не найдены', tabTools: 'Инструменты', + tabResources: 'Ресурсы', tabDocs: 'Документация', noReadme: 'Документация отсутствует', parseResultFailed: 'Не удалось разобрать результат теста', @@ -855,6 +857,12 @@ const ruRU = { toolCount: 'Инструменты: {{count}}', parameterCount: 'Параметры: {{count}}', noParameters: 'Нет параметров', + resourceCount: 'Ресурсы: {{count}}', + resourceBinaryContent: 'Двоичное содержимое (невозможно отобразить)', + resourceBinaryOmitted: + 'Двоичное содержимое опущено согласно политике безопасности ресурсов', + resourceTruncated: 'Содержимое усечено по лимитам байтов или токенов', + resourceReadFailed: 'Не удалось прочитать содержимое ресурса', statusConnected: 'Подключён', statusDisconnected: 'Отключён', statusError: 'Ошибка подключения', @@ -945,9 +953,12 @@ const ruRU = { selectPlugins: 'Выберите плагины', pluginsTitle: 'Плагины', mcpServersTitle: 'MCP-серверы', + mcpResourcesTitle: 'MCP-ресурсы', noMCPServersSelected: 'MCP-серверы не выбраны', + noMCPResourcesAvailable: 'Нет доступных MCP-ресурсов', addMCPServer: 'Добавить MCP-сервер', selectMCPServers: 'Выберите MCP-серверы', + enableMCPResourceAgentRead: 'Разрешить модели чтение', toolCount: '{{count}} инструментов', noPluginsInstalled: 'Нет установленных плагинов', noMCPServersConfigured: 'Нет настроенных MCP-серверов', @@ -963,6 +974,40 @@ const ruRU = { addSkill: 'Добавить навык', selectSkills: 'Выбрать навыки', noSkillsAvailable: 'Нет доступных навыков', + mcpServersScopeTooltip: + 'Здесь задаётся только привязка MCP-серверов к конвейеру. Конкретные MCP-инструменты и ресурсы выбираются в AI Feature > Local Agent.', + enableAllMCPServersTooltip: + 'Если включено, все настроенные и включённые MCP-серверы станут кандидатами для инструментов и ресурсов MCP в AI Feature.', + }, + localAgent: { + toolsTitle: 'Инструменты', + toolsDescription: + 'Выберите инструменты плагинов, MCP и встроенные инструменты для этого Local Agent.', + toolsScopeTooltip: + 'MCP-инструменты берутся только из MCP-серверов, привязанных в Расширениях. Чтобы добавить источник, сначала привяжите там сервер.', + enableAllTools: 'Включить все инструменты', + allToolsEnabled: 'Все доступные инструменты включены', + noToolsSelected: 'Инструменты не выбраны', + editTools: 'Редактировать инструменты', + builtinTools: 'Встроенные инструменты', + pluginTools: 'Инструменты плагинов', + skillTools: 'Инструменты навыков', + mcpTools: 'Инструменты MCP', + mcpToolsScopeTooltip: + 'Здесь показаны только инструменты MCP-серверов, разрешённых сейчас в Расширениях.', + selectTools: 'Выбрать инструменты', + resourcesTitle: 'Ресурсы', + resourcesDescription: + 'Выберите MCP-ресурсы и базы знаний для этого Local Agent.', + knowledgeBases: 'Базы знаний', + mcpResources: 'MCP-ресурсы', + mcpResourcesScopeTooltip: + 'Здесь показаны только ресурсы MCP-серверов, разрешённых сейчас в Расширениях.', + enableMCPResourceRead: 'Разрешить модели читать MCP-ресурсы', + mcpResourceReadTooltip: + 'Если выключено, выбранные MCP-ресурсы не будут добавляться в контекст модели.', + noMCPResourcesAvailable: 'Нет доступных MCP-ресурсов', + selectKnowledgeBases: 'Выбрать базы знаний', }, debugDialog: { title: 'Чат конвейера', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 41362738a..6e69fd702 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -813,7 +813,9 @@ const thTH = { toolsFound: 'เครื่องมือ', unknownError: 'ข้อผิดพลาดที่ไม่ทราบสาเหตุ', noToolsFound: 'ไม่พบเครื่องมือ', + noResourcesFound: 'ไม่พบทรัพยากร', tabTools: 'เครื่องมือ', + tabResources: 'ทรัพยากร', tabDocs: 'เอกสาร', noReadme: 'ไม่มีเอกสาร', parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้', @@ -833,6 +835,11 @@ const thTH = { toolCount: 'เครื่องมือ: {{count}}', parameterCount: 'พารามิเตอร์: {{count}}', noParameters: 'ไม่มีพารามิเตอร์', + resourceCount: 'ทรัพยากร: {{count}}', + resourceBinaryContent: 'เนื้อหาไบนารี (ไม่สามารถแสดงได้)', + resourceBinaryOmitted: 'ละเว้นเนื้อหาไบนารีตามนโยบายความปลอดภัยของทรัพยากร', + resourceTruncated: 'ตัดเนื้อหาตามขีดจำกัดไบต์หรือโทเคน', + resourceReadFailed: 'ไม่สามารถอ่านเนื้อหาทรัพยากรได้', statusConnected: 'เชื่อมต่อแล้ว', statusDisconnected: 'ไม่ได้เชื่อมต่อ', statusError: 'ข้อผิดพลาดการเชื่อมต่อ', @@ -922,9 +929,12 @@ const thTH = { selectPlugins: 'เลือกปลั๊กอิน', pluginsTitle: 'ปลั๊กอิน', mcpServersTitle: 'เซิร์ฟเวอร์ MCP', + mcpResourcesTitle: 'ทรัพยากร MCP', noMCPServersSelected: 'ไม่ได้เลือกเซิร์ฟเวอร์ MCP', + noMCPResourcesAvailable: 'ไม่มีทรัพยากร MCP ที่พร้อมใช้งาน', addMCPServer: 'เพิ่มเซิร์ฟเวอร์ MCP', selectMCPServers: 'เลือกเซิร์ฟเวอร์ MCP', + enableMCPResourceAgentRead: 'อนุญาตให้โมเดลอ่าน', toolCount: '{{count}} เครื่องมือ', noPluginsInstalled: 'ไม่มีปลั๊กอินที่ติดตั้ง', noMCPServersConfigured: 'ไม่มีเซิร์ฟเวอร์ MCP ที่กำหนดค่า', @@ -940,6 +950,40 @@ const thTH = { addSkill: 'เพิ่มสกิล', selectSkills: 'เลือกสกิล', noSkillsAvailable: 'ไม่มีสกิลที่พร้อมใช้งาน', + mcpServersScopeTooltip: + 'ส่วนนี้ใช้ควบคุมว่า Pipeline ผูกกับเซิร์ฟเวอร์ MCP ใดเท่านั้น ส่วนเครื่องมือและทรัพยากร MCP รายตัวให้เลือกใน AI Feature > Local Agent', + enableAllMCPServersTooltip: + 'เมื่อเปิดใช้ เซิร์ฟเวอร์ MCP ที่ตั้งค่าและเปิดใช้งานทั้งหมดจะเป็นตัวเลือกสำหรับเครื่องมือและทรัพยากร MCP ใน AI Feature', + }, + localAgent: { + toolsTitle: 'เครื่องมือ', + toolsDescription: + 'เลือกเครื่องมือจากปลั๊กอิน MCP และเครื่องมือในตัวสำหรับ Local Agent นี้', + toolsScopeTooltip: + 'เครื่องมือ MCP จะแสดงจากเซิร์ฟเวอร์ MCP ที่ผูกไว้ในส่วนขยายเท่านั้น หากต้องการเพิ่มแหล่งเครื่องมือ ให้ไปผูกเซิร์ฟเวอร์ที่นั่นก่อน', + enableAllTools: 'เปิดใช้เครื่องมือทั้งหมด', + allToolsEnabled: 'เปิดใช้เครื่องมือที่มีทั้งหมดแล้ว', + noToolsSelected: 'ยังไม่ได้เลือกเครื่องมือ', + editTools: 'แก้ไขเครื่องมือ', + builtinTools: 'เครื่องมือในตัว', + pluginTools: 'เครื่องมือปลั๊กอิน', + skillTools: 'เครื่องมือสกิล', + mcpTools: 'เครื่องมือ MCP', + mcpToolsScopeTooltip: + 'ที่นี่จะแสดงเฉพาะเครื่องมือจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย', + selectTools: 'เลือกเครื่องมือ', + resourcesTitle: 'ทรัพยากร', + resourcesDescription: + 'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Local Agent นี้', + knowledgeBases: 'คลังความรู้', + mcpResources: 'ทรัพยากร MCP', + mcpResourcesScopeTooltip: + 'ที่นี่จะแสดงเฉพาะทรัพยากรจากเซิร์ฟเวอร์ MCP ที่อนุญาตอยู่ในส่วนขยาย', + enableMCPResourceRead: 'อนุญาตให้โมเดลอ่านทรัพยากร MCP', + mcpResourceReadTooltip: + 'เมื่อปิด ทรัพยากร MCP ที่เลือกไว้จะไม่ถูกใส่เข้าไปในบริบทของโมเดล', + noMCPResourcesAvailable: 'ไม่มีทรัพยากร MCP ที่พร้อมใช้งาน', + selectKnowledgeBases: 'เลือกคลังความรู้', }, debugDialog: { title: 'แชท Pipeline', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index a7c838ab5..cb3c84a54 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -828,7 +828,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', @@ -848,6 +850,12 @@ 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ị)', + resourceBinaryOmitted: + 'Nội dung nhị phân đã bị lược bỏ theo chính sách an toàn tài nguyên', + resourceTruncated: 'Nội dung đã bị cắt theo giới hạn byte hoặc token', + 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', @@ -937,9 +945,12 @@ const viVN = { selectPlugins: 'Chọn Plugin', pluginsTitle: 'Plugin', mcpServersTitle: 'Máy chủ MCP', + mcpResourcesTitle: 'Tài nguyên MCP', noMCPServersSelected: 'Chưa chọn máy chủ MCP nào', + noMCPResourcesAvailable: 'Không có tài nguyên MCP nào', addMCPServer: 'Thêm máy chủ MCP', selectMCPServers: 'Chọn máy chủ MCP', + enableMCPResourceAgentRead: 'Cho phép mô hình đọc', toolCount: '{{count}} công cụ', noPluginsInstalled: 'Chưa cài đặt plugin nào', noMCPServersConfigured: 'Chưa cấu hình máy chủ MCP nào', @@ -955,6 +966,40 @@ const viVN = { addSkill: 'Thêm kỹ năng', selectSkills: 'Chọn kỹ năng', noSkillsAvailable: 'Không có kỹ năng khả dụng', + mcpServersScopeTooltip: + 'Tại đây chỉ kiểm soát máy chủ MCP được liên kết với Pipeline. Công cụ và tài nguyên MCP cụ thể được chọn trong AI Feature > Local Agent.', + enableAllMCPServersTooltip: + 'Khi bật, mọi máy chủ MCP đã cấu hình và bật sẽ trở thành ứng viên cho công cụ và tài nguyên MCP trong AI Feature.', + }, + localAgent: { + toolsTitle: 'Công cụ', + toolsDescription: + 'Chọn công cụ plugin, MCP và công cụ tích hợp sẵn cho Local Agent này.', + toolsScopeTooltip: + 'Công cụ MCP chỉ đến từ máy chủ MCP đã liên kết trong Tiện ích mở rộng. Hãy liên kết máy chủ tại đó trước nếu muốn chọn thêm tại đây.', + enableAllTools: 'Bật tất cả công cụ', + allToolsEnabled: 'Tất cả công cụ khả dụng đã được bật', + noToolsSelected: 'Chưa chọn công cụ nào', + editTools: 'Sửa công cụ', + builtinTools: 'Công cụ tích hợp sẵn', + pluginTools: 'Công cụ plugin', + skillTools: 'Công cụ kỹ năng', + mcpTools: 'Công cụ MCP', + mcpToolsScopeTooltip: + 'Tại đây chỉ hiển thị công cụ từ máy chủ MCP hiện được cho phép trong Tiện ích mở rộng.', + selectTools: 'Chọn công cụ', + resourcesTitle: 'Tài nguyên', + resourcesDescription: + 'Chọn tài nguyên MCP và kho tri thức cho Local Agent này.', + knowledgeBases: 'Kho tri thức', + mcpResources: 'Tài nguyên MCP', + mcpResourcesScopeTooltip: + 'Tại đây chỉ hiển thị tài nguyên từ máy chủ MCP hiện được cho phép trong Tiện ích mở rộng.', + enableMCPResourceRead: 'Cho phép mô hình đọc tài nguyên MCP', + mcpResourceReadTooltip: + 'Khi tắt, tài nguyên MCP đã chọn sẽ không được đưa vào ngữ cảnh mô hình.', + noMCPResourcesAvailable: 'Không có tài nguyên MCP nào', + selectKnowledgeBases: 'Chọn kho tri thức', }, debugDialog: { title: 'Trò chuyện Pipeline', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9a825ad32..07def167c 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -790,7 +790,9 @@ const zhHans = { toolsFound: '个工具', unknownError: '未知错误', noToolsFound: '未找到任何工具', + noResourcesFound: '未找到任何资源', tabTools: '工具', + tabResources: '资源', tabDocs: '文档', noReadme: '暂无文档', parseResultFailed: '解析测试结果失败', @@ -810,6 +812,11 @@ const zhHans = { toolCount: '工具:{{count}}', parameterCount: '参数:{{count}}', noParameters: '无参数', + resourceCount: '资源:{{count}}', + resourceBinaryContent: '二进制内容(无法显示)', + resourceBinaryOmitted: '二进制内容已按资源安全策略省略', + resourceTruncated: '内容已按字节或 token 限制截断', + resourceReadFailed: '读取资源内容失败', statusConnected: '已连接', statusDisconnected: '未连接', statusError: '连接错误', @@ -895,9 +902,12 @@ const zhHans = { selectPlugins: '选择插件', pluginsTitle: '插件', mcpServersTitle: 'MCP 服务器', + mcpResourcesTitle: 'MCP 资源', noMCPServersSelected: '未选择任何 MCP 服务器', + noMCPResourcesAvailable: '暂无可用 MCP 资源', addMCPServer: '添加 MCP 服务器', selectMCPServers: '选择 MCP 服务器', + enableMCPResourceAgentRead: '允许模型读取', toolCount: '{{count}} 个工具', noPluginsInstalled: '无已安装的插件', noMCPServersConfigured: '无已配置的 MCP 服务器', @@ -913,6 +923,41 @@ const zhHans = { addSkill: '添加技能', selectSkills: '选择技能', noSkillsAvailable: '暂无可用技能', + mcpServersScopeTooltip: + '这里仅控制此流水线绑定哪些 MCP 服务器;具体 MCP 工具和资源在 AI 能力的内置 Agent 表单中选择。', + enableAllMCPServersTooltip: + '开启后,所有已配置且启用的 MCP 服务器都会进入 AI 能力里的 MCP 工具和资源候选范围。', + }, + localAgent: { + toolsTitle: '工具', + toolsDescription: + '选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。', + toolsScopeTooltip: + 'MCP 工具只会从扩展集成中已绑定的 MCP 服务器里出现;如需增加 MCP 工具来源,请先到扩展集成绑定对应服务器。', + enableAllTools: '启用所有工具', + allToolsEnabled: '已启用所有可用工具', + noToolsSelected: '未选择任何工具', + editTools: '编辑工具', + builtinTools: '内置工具', + pluginTools: '插件工具', + skillTools: '技能工具', + mcpTools: 'MCP 工具', + mcpToolsScopeTooltip: + '这里仅展示扩展集成当前允许的 MCP 服务器提供的工具。', + skillToolsScopeTooltip: + '技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。', + selectTools: '选择工具', + resourcesTitle: '资源', + resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。', + knowledgeBases: '知识库', + mcpResources: 'MCP 资源', + mcpResourcesScopeTooltip: + '这里仅展示扩展集成当前允许的 MCP 服务器暴露的资源。', + enableMCPResourceRead: '允许模型读取 MCP 资源', + mcpResourceReadTooltip: + '关闭后,即使已选择资源,也不会把 MCP 资源内容注入给模型。', + noMCPResourcesAvailable: '暂无可用 MCP 资源', + selectKnowledgeBases: '选择知识库', }, debugDialog: { title: '流水线对话', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 4e9bf303a..be438d698 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -789,7 +789,9 @@ const zhHant = { toolsFound: '個工具', unknownError: '未知錯誤', noToolsFound: '未找到任何工具', + noResourcesFound: '未找到任何資源', tabTools: '工具', + tabResources: '資源', tabDocs: '文件', noReadme: '暫無文件', parseResultFailed: '解析測試結果失敗', @@ -809,6 +811,11 @@ const zhHant = { toolCount: '工具:{{count}}', parameterCount: '參數:{{count}}', noParameters: '無參數', + resourceCount: '資源:{{count}}', + resourceBinaryContent: '二進位內容(無法顯示)', + resourceBinaryOmitted: '二進位內容已依資源安全策略省略', + resourceTruncated: '內容已依位元組或 token 限制截斷', + resourceReadFailed: '讀取資源內容失敗', statusConnected: '已連線', statusDisconnected: '未連線', statusError: '連接錯誤', @@ -894,9 +901,12 @@ const zhHant = { selectPlugins: '選擇插件', pluginsTitle: '插件', mcpServersTitle: 'MCP 伺服器', + mcpResourcesTitle: 'MCP 資源', noMCPServersSelected: '未選擇任何 MCP 伺服器', + noMCPResourcesAvailable: '暫無可用 MCP 資源', addMCPServer: '新增 MCP 伺服器', selectMCPServers: '選擇 MCP 伺服器', + enableMCPResourceAgentRead: '允許模型讀取', toolCount: '{{count}} 個工具', noPluginsInstalled: '無已安裝的插件', noMCPServersConfigured: '無已配置的 MCP 伺服器', @@ -912,6 +922,38 @@ const zhHant = { addSkill: '新增技能', selectSkills: '選擇技能', noSkillsAvailable: '暫無可用技能', + mcpServersScopeTooltip: + '這裡僅控制此流程線綁定哪些 MCP 伺服器;具體 MCP 工具和資源在 AI 能力的內建 Agent 表單中選擇。', + enableAllMCPServersTooltip: + '啟用後,所有已配置且啟用的 MCP 伺服器都會進入 AI 能力中的 MCP 工具和資源候選範圍。', + }, + localAgent: { + toolsTitle: '工具', + toolsDescription: '選擇此內建 Agent 可以調用的插件、MCP 和內建工具。', + toolsScopeTooltip: + 'MCP 工具只會從擴展集成中已綁定的 MCP 伺服器裡出現;如需增加 MCP 工具來源,請先到擴展集成綁定對應伺服器。', + enableAllTools: '啟用所有工具', + allToolsEnabled: '已啟用所有可用工具', + noToolsSelected: '未選擇任何工具', + editTools: '編輯工具', + builtinTools: '內建工具', + pluginTools: '插件工具', + skillTools: '技能工具', + mcpTools: 'MCP 工具', + mcpToolsScopeTooltip: + '這裡僅展示擴展集成目前允許的 MCP 伺服器提供的工具。', + selectTools: '選擇工具', + resourcesTitle: '資源', + resourcesDescription: '選擇此內建 Agent 可以讀取的 MCP 資源和知識庫。', + knowledgeBases: '知識庫', + mcpResources: 'MCP 資源', + mcpResourcesScopeTooltip: + '這裡僅展示擴展集成目前允許的 MCP 伺服器暴露的資源。', + enableMCPResourceRead: '允許模型讀取 MCP 資源', + mcpResourceReadTooltip: + '關閉後,即使已選擇資源,也不會把 MCP 資源內容注入給模型。', + noMCPResourcesAvailable: '暫無可用 MCP 資源', + selectKnowledgeBases: '選擇知識庫', }, debugDialog: { title: '流程線對話',