Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion astrbot/core/agent/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)

from astrbot import logger
from astrbot.core.agent.mcp_scope import strip_mcp_scope_fields
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.utils.log_pipe import LogPipe

Expand Down Expand Up @@ -42,6 +43,7 @@ def _prepare_config(config: dict) -> dict:
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
config.pop("active", None)
strip_mcp_scope_fields(config)
return config


Expand Down Expand Up @@ -364,7 +366,12 @@ class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""

def __init__(
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
self,
mcp_tool: mcp.Tool,
mcp_client: MCPClient,
mcp_server_name: str,
mcp_server_scopes: tuple[str, ...] | None = None,
**kwargs,
) -> None:
super().__init__(
name=mcp_tool.name,
Expand All @@ -374,6 +381,7 @@ def __init__(
self.mcp_tool = mcp_tool
self.mcp_client = mcp_client
self.mcp_server_name = mcp_server_name
self.mcp_server_scopes = mcp_server_scopes

async def call(
self, context: ContextWrapper[TContext], **kwargs
Expand Down
88 changes: 88 additions & 0 deletions astrbot/core/agent/mcp_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from __future__ import annotations

from collections.abc import Iterable, Mapping
from typing import Any

MCP_SCOPE_FIELDS = ("scopes", "agent_scope")
MCP_SCOPE_WILDCARDS = {"*", "all"}


def _normalize_scope_token(value: Any) -> str | None:
token = str(value).strip().lower()
return token or None


def normalize_mcp_scope_value(raw_scope: Any) -> tuple[str, ...] | None:
"""Normalize a raw MCP scope value.

Returns:
- None: no scope restriction (visible to all agents).
- tuple[str, ...]: explicit allow list; empty tuple means visible to none.
"""
if raw_scope is None:
return None

if isinstance(raw_scope, str):
values: Iterable[Any] = [raw_scope]
elif isinstance(raw_scope, Mapping):
return ()
elif isinstance(raw_scope, Iterable):
values = raw_scope
else:
return ()

normalized: list[str] = []
for value in values:
token = _normalize_scope_token(value)
if not token:
continue
if token in MCP_SCOPE_WILDCARDS:
return ("*",)
if token not in normalized:
normalized.append(token)
return tuple(normalized)


def get_mcp_scopes_from_config(
config: Mapping[str, Any] | None,
) -> tuple[str, ...] | None:
"""Extract and normalize MCP scope config from server config."""
if not isinstance(config, Mapping):
return None

raw_scope = config.get("scopes", None)
if raw_scope is None:
raw_scope = config.get("agent_scope", None)

return normalize_mcp_scope_value(raw_scope)


def strip_mcp_scope_fields(config: dict[str, Any]) -> None:
"""Remove MCP scope-only fields before passing config to MCP transport layer."""
for key in MCP_SCOPE_FIELDS:
config.pop(key, None)


def is_scope_allowed_for_agent(
scopes: tuple[str, ...] | None,
agent_name: str | None,
) -> bool:
"""Return whether an agent can see a tool with the given scopes."""
if scopes is None:
return True

if "*" in scopes:
return True

normalized_agent = _normalize_scope_token(agent_name)
if not normalized_agent:
return False
return normalized_agent in scopes


def is_mcp_tool_visible_to_agent(tool: Any, agent_name: str | None) -> bool:
"""Return whether an MCP tool is visible to the target agent."""
return is_scope_allowed_for_agent(
normalize_mcp_scope_value(getattr(tool, "mcp_server_scopes", None)),
agent_name,
)
10 changes: 10 additions & 0 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from astrbot import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.mcp_scope import is_mcp_tool_visible_to_agent
from astrbot.core.agent.message import Message
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolSet
Expand Down Expand Up @@ -95,14 +96,23 @@ async def _execute_handoff(

# make toolset for the agent
tools = tool.agent.tools
target_agent_name = tool.agent.name or ""
if tools:
toolset = ToolSet()
for t in tools:
if isinstance(t, str):
_t = llm_tools.get_func(t)
if isinstance(_t, MCPTool) and not is_mcp_tool_visible_to_agent(
_t, target_agent_name
):
continue
if _t:
toolset.add_tool(_t)
elif isinstance(t, FunctionTool):
if isinstance(t, MCPTool) and not is_mcp_tool_visible_to_agent(
t, target_agent_name
):
continue
toolset.add_tool(t)
else:
toolset = None
Expand Down
113 changes: 88 additions & 25 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from astrbot.core import logger
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.mcp_scope import is_mcp_tool_visible_to_agent
from astrbot.core.agent.message import TextPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext
Expand Down Expand Up @@ -392,6 +393,12 @@ async def _ensure_persona_and_skills(
tool.name
for tool in tmgr.func_list
if not isinstance(tool, HandoffTool)
and (
not isinstance(tool, MCPTool)
or is_mcp_tool_visible_to_agent(
tool, str(a.get("name", ""))
)
)
]
)
continue
Expand All @@ -400,6 +407,13 @@ async def _ensure_persona_and_skills(
for t in tools:
name = str(t).strip()
if name:
tool = tmgr.get_func(name)
if isinstance(
tool, MCPTool
) and not is_mcp_tool_visible_to_agent(
tool, str(a.get("name", ""))
):
continue
assigned_tools.add(name)

if req.func_tool is None:
Expand Down Expand Up @@ -748,36 +762,84 @@ def _sanitize_context_by_modalities(
req.contexts = sanitized_contexts


def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
def _filter_visible_mcp_tools(
tools: ToolSet | None,
agent_name: str,
) -> ToolSet | None:
if not tools:
return tools

filtered = ToolSet()
for tool in tools.tools:
if isinstance(tool, MCPTool) and not is_mcp_tool_visible_to_agent(
tool, agent_name
):
continue
filtered.add_tool(tool)
return filtered


def _filter_tools_by_plugins(
tools: ToolSet,
plugins_name: set[str],
) -> ToolSet:
filtered = ToolSet()
for tool in tools.tools:
if isinstance(tool, MCPTool):
# MCP visibility has already been filtered by agent scope.
filtered.add_tool(tool)
continue

mp = tool.handler_module_path
if not mp:
continue
plugin = star_map.get(mp)
if not plugin:
continue
if plugin.name in plugins_name or plugin.reserved:
filtered.add_tool(tool)

return filtered


def _inject_global_mcp_tools(
base_tools: ToolSet | None,
agent_name: str,
) -> ToolSet:
result = ToolSet()
if base_tools:
for tool in base_tools.tools:
result.add_tool(tool)

for tool in llm_tools.func_list:
if isinstance(tool, MCPTool) and is_mcp_tool_visible_to_agent(tool, agent_name):
result.add_tool(tool)
return result


def _plugin_tool_fix(
event: AstrMessageEvent,
req: ProviderRequest,
inject_mcp: bool = True,
*,
agent_name: str = "main",
) -> None:
"""根据事件中的插件设置,过滤请求中的工具列表。

注意:没有 handler_module_path 的工具(如 MCP 工具)会被保留,
因为它们不属于任何插件,不应被插件过滤逻辑影响。

Args:
event: 消息事件
req: 提供者请求
inject_mcp: 是否自动注入全局 MCP 工具,默认 True(向后兼容)
"""
req.func_tool = _filter_visible_mcp_tools(req.func_tool, agent_name)

if event.plugins_name is not None and req.func_tool:
new_tool_set = ToolSet()
for tool in req.func_tool.tools:
if isinstance(tool, MCPTool):
# 保留 MCP 工具
new_tool_set.add_tool(tool)
continue
mp = tool.handler_module_path
if not mp:
continue
plugin = star_map.get(mp)
if not plugin:
continue
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
req.func_tool = new_tool_set
else:
# mcp tools
tool_set = req.func_tool
if not tool_set:
tool_set = ToolSet()
for tool in llm_tools.func_list:
if isinstance(tool, MCPTool):
tool_set.add_tool(tool)
req.func_tool = _filter_tools_by_plugins(req.func_tool, set(event.plugins_name))
elif inject_mcp:
req.func_tool = _inject_global_mcp_tools(req.func_tool, agent_name)


async def _handle_webchat(
Expand Down Expand Up @@ -1074,7 +1136,8 @@ async def build_main_agent(
req.session_id = event.unified_msg_origin

_modalities_fix(provider, req)
_plugin_tool_fix(event, req)
inject_mcp = config.subagent_orchestrator.get("inject_global_mcp_tools", True)
_plugin_tool_fix(event, req, inject_mcp, agent_name="main")
_sanitize_context_by_modalities(config, provider, req)

if config.llm_safety_mode:
Expand Down
1 change: 1 addition & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"subagent_orchestrator": {
"main_enable": False,
"remove_main_duplicate_tools": False,
"inject_global_mcp_tools": True,
"router_system_prompt": (
"You are a task router. Your job is to chat naturally, recognize user intent, "
"and delegate work to the most suitable subagent using transfer_to_* tools. "
Expand Down
7 changes: 7 additions & 0 deletions astrbot/core/provider/func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
from astrbot import logger
from astrbot.core import sp
from astrbot.core.agent.mcp_client import MCPClient, MCPTool
from astrbot.core.agent.mcp_scope import (
get_mcp_scopes_from_config,
strip_mcp_scope_fields,
)
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.utils.astrbot_path import get_astrbot_data_path

Expand Down Expand Up @@ -45,6 +49,7 @@ def _prepare_config(config: dict) -> dict:
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
config.pop("active", None)
strip_mcp_scope_fields(config)
return config


Expand Down Expand Up @@ -250,6 +255,7 @@ async def _init_mcp_client(self, name: str, config: dict) -> None:
# 先清理之前的客户端,如果存在
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
mcp_server_scopes = get_mcp_scopes_from_config(config)

mcp_client = MCPClient()
mcp_client.name = name
Expand All @@ -272,6 +278,7 @@ async def _init_mcp_client(self, name: str, config: dict) -> None:
mcp_tool=tool,
mcp_client=mcp_client,
mcp_server_name=name,
mcp_server_scopes=mcp_server_scopes,
)
self.func_list.append(func_tool)

Expand Down
8 changes: 8 additions & 0 deletions astrbot/dashboard/routes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,15 +329,21 @@ async def get_tool_list(self):
if isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
mcp_server_name = tool.mcp_server_name
mcp_server_scopes = tool.mcp_server_scopes
elif tool.handler_module_path and star_map.get(
tool.handler_module_path
):
star = star_map[tool.handler_module_path]
origin = "plugin"
origin_name = star.name
mcp_server_name = None
mcp_server_scopes = None
else:
origin = "unknown"
origin_name = "unknown"
mcp_server_name = None
mcp_server_scopes = None

tool_info = {
"name": tool.name,
Expand All @@ -346,6 +352,8 @@ async def get_tool_list(self):
"active": tool.active,
"origin": origin,
"origin_name": origin_name,
"mcp_server_name": mcp_server_name,
"mcp_server_scopes": mcp_server_scopes,
}
tools_dict.append(tool_info)
return Response().ok(data=tools_dict).__dict__
Expand Down
Loading