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 <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
advancer-young
2026-06-30 19:16:30 +08:00
committed by GitHub
parent 2618e06492
commit 096ec1a8ce
39 changed files with 4103 additions and 201 deletions
@@ -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()
@@ -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/<server_name>/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/<server_name>/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/<server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(server_name: str) -> str:
"""Read a resource from an MCP server"""
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)}')
@@ -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('/<tool_name>', 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:
+26
View File
@@ -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"""
@@ -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)
@@ -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,
},
)
+11
View File
@@ -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:
+25 -3
View File
@@ -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 = ''
@@ -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:
File diff suppressed because it is too large Load Diff
@@ -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():
+43 -1
View File
@@ -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 (
+28 -14
View File
@@ -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