feat: add MCP tool integration
This commit is contained in:
@@ -27,6 +27,7 @@ from routes import (
|
|||||||
conversations,
|
conversations,
|
||||||
health,
|
health,
|
||||||
knowledge_bases,
|
knowledge_bases,
|
||||||
|
mcp_servers,
|
||||||
model_registry,
|
model_registry,
|
||||||
node_types,
|
node_types,
|
||||||
tools,
|
tools,
|
||||||
@@ -58,6 +59,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(conversations.router)
|
app.include_router(conversations.router)
|
||||||
app.include_router(assistants.router)
|
app.include_router(assistants.router)
|
||||||
app.include_router(knowledge_bases.router)
|
app.include_router(knowledge_bases.router)
|
||||||
|
app.include_router(mcp_servers.router)
|
||||||
app.include_router(model_registry.router)
|
app.include_router(model_registry.router)
|
||||||
app.include_router(node_types.router)
|
app.include_router(node_types.router)
|
||||||
app.include_router(tools.router)
|
app.include_router(tools.router)
|
||||||
|
|||||||
@@ -207,15 +207,55 @@ class AssistantModelBinding(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class McpServer(Base):
|
||||||
|
"""Workspace-level remote MCP connection used to discover executable tools."""
|
||||||
|
|
||||||
|
__tablename__ = "mcp_servers"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
|
transport: Mapped[str] = mapped_column(String(32), default="streamable_http")
|
||||||
|
url: Mapped[str] = mapped_column(String(2048))
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), index=True, default="active")
|
||||||
|
last_synced_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Tool(Base):
|
class Tool(Base):
|
||||||
"""Reusable LLM tool definition; supported types are executed at runtime."""
|
"""Reusable LLM tool definition; supported types are executed at runtime."""
|
||||||
|
|
||||||
__tablename__ = "tools"
|
__tablename__ = "tools"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"mcp_server_id",
|
||||||
|
"remote_tool_name",
|
||||||
|
name="uq_tools_mcp_server_remote_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128))
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
function_name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
function_name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
type: Mapped[str] = mapped_column(String(24), index=True)
|
type: Mapped[str] = mapped_column(String(24), index=True)
|
||||||
|
mcp_server_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(40),
|
||||||
|
ForeignKey("mcp_servers.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
remote_tool_name: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
description: Mapped[str] = mapped_column(String(2048), default="")
|
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
|
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|||||||
107
backend/migrations/versions/20260717_0008_add_mcp_servers.py
Normal file
107
backend/migrations/versions/20260717_0008_add_mcp_servers.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""add MCP server connections and discovered tools
|
||||||
|
|
||||||
|
Revision ID: 20260717_0008
|
||||||
|
Revises: 20260712_0007
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260717_0008"
|
||||||
|
down_revision: str | Sequence[str] | None = "20260712_0007"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"mcp_servers",
|
||||||
|
sa.Column("id", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"description",
|
||||||
|
sa.String(length=2048),
|
||||||
|
server_default="",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"transport",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="streamable_http",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("url", sa.String(length=2048), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"config",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default=sa.text("'{}'::jsonb"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"secrets",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
server_default=sa.text("'{}'::jsonb"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.String(length=16),
|
||||||
|
server_default="active",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mcp_servers_status", "mcp_servers", ["status"])
|
||||||
|
op.add_column(
|
||||||
|
"tools",
|
||||||
|
sa.Column("mcp_server_id", sa.String(length=40), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"tools",
|
||||||
|
sa.Column("remote_tool_name", sa.String(length=255), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tools_mcp_server_id", "tools", ["mcp_server_id"])
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tools_mcp_server_id",
|
||||||
|
"tools",
|
||||||
|
"mcp_servers",
|
||||||
|
["mcp_server_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_tools_mcp_server_remote_name",
|
||||||
|
"tools",
|
||||||
|
["mcp_server_id", "remote_tool_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_tools_mcp_server_remote_name",
|
||||||
|
"tools",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
op.drop_constraint("fk_tools_mcp_server_id", "tools", type_="foreignkey")
|
||||||
|
op.drop_index("ix_tools_mcp_server_id", table_name="tools")
|
||||||
|
op.drop_column("tools", "remote_tool_name")
|
||||||
|
op.drop_column("tools", "mcp_server_id")
|
||||||
|
op.drop_index("ix_mcp_servers_status", table_name="mcp_servers")
|
||||||
|
op.drop_table("mcp_servers")
|
||||||
@@ -15,6 +15,18 @@ from pydantic import BaseModel, Field
|
|||||||
RuntimeMode = Literal["pipeline", "realtime"]
|
RuntimeMode = Literal["pipeline", "realtime"]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeMcpServer(BaseModel):
|
||||||
|
"""Resolved MCP connection containing secrets for one runtime session."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
transport: str = "streamable_http"
|
||||||
|
url: str
|
||||||
|
timeout_seconds: int = 30
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
secret_headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeTool(BaseModel):
|
class RuntimeTool(BaseModel):
|
||||||
"""Tool data resolved from an assistant binding for one runtime session."""
|
"""Tool data resolved from an assistant binding for one runtime session."""
|
||||||
|
|
||||||
@@ -25,6 +37,7 @@ class RuntimeTool(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
definition: dict = Field(default_factory=dict)
|
definition: dict = Field(default_factory=dict)
|
||||||
secrets: dict = Field(default_factory=dict)
|
secrets: dict = Field(default_factory=dict)
|
||||||
|
mcp_server: RuntimeMcpServer | None = None
|
||||||
|
|
||||||
|
|
||||||
class RuntimeModelResource(BaseModel):
|
class RuntimeModelResource(BaseModel):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
|
||||||
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
# silero -> 本地 VAD(判断用户说话起止),语音必备
|
||||||
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
|
||||||
pipecat-ai[webrtc,websocket,silero,openai]==1.5.0
|
pipecat-ai[webrtc,websocket,silero,openai,mcp]==1.5.0
|
||||||
Pillow>=11.1.0,<13
|
Pillow>=11.1.0,<13
|
||||||
|
|
||||||
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)
|
||||||
|
|||||||
304
backend/routes/mcp_servers.py
Normal file
304
backend/routes/mcp_servers.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""Workspace MCP Server CRUD and explicit remote tool synchronization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from db.models import AssistantToolBinding, McpServer, Tool
|
||||||
|
from db.session import get_session
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from models import RuntimeMcpServer
|
||||||
|
from schemas import McpServerOut, McpServerUpsert, McpSyncResult, ToolOut
|
||||||
|
from services.auth import require_admin
|
||||||
|
from services.masking import mask_secrets, merge_secrets
|
||||||
|
from services.tool_resources import tool_to_out
|
||||||
|
from services.tools import McpClientError, McpToolClient
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/mcp-servers",
|
||||||
|
tags=["mcp-servers"],
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_server(server: McpServer) -> RuntimeMcpServer:
|
||||||
|
config = server.config or {}
|
||||||
|
secrets = server.secrets or {}
|
||||||
|
return RuntimeMcpServer(
|
||||||
|
id=server.id,
|
||||||
|
name=server.name,
|
||||||
|
transport=server.transport,
|
||||||
|
url=server.url,
|
||||||
|
timeout_seconds=int(config.get("timeout_seconds") or 30),
|
||||||
|
headers={
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in (config.get("headers") or {}).items()
|
||||||
|
},
|
||||||
|
secret_headers={
|
||||||
|
str(key): str(value)
|
||||||
|
for key, value in (secrets.get("headers") or {}).items()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_out(session: AsyncSession, server: McpServer) -> McpServerOut:
|
||||||
|
tool_count = int(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(Tool.id)).where(Tool.mcp_server_id == server.id)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
config = server.config or {}
|
||||||
|
secrets = server.secrets or {}
|
||||||
|
return McpServerOut(
|
||||||
|
id=server.id,
|
||||||
|
name=server.name,
|
||||||
|
description=server.description,
|
||||||
|
transport=server.transport, # type: ignore[arg-type]
|
||||||
|
url=server.url,
|
||||||
|
timeout_seconds=int(config.get("timeout_seconds") or 30),
|
||||||
|
headers=dict(config.get("headers") or {}),
|
||||||
|
secret_headers=mask_secrets(secrets.get("headers") or {}),
|
||||||
|
status=server.status, # type: ignore[arg-type]
|
||||||
|
tool_count=tool_count,
|
||||||
|
last_synced_at=(
|
||||||
|
server.last_synced_at.isoformat() if server.last_synced_at else None
|
||||||
|
),
|
||||||
|
updated_at=server.updated_at.isoformat() if server.updated_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_body(
|
||||||
|
server: McpServer,
|
||||||
|
body: McpServerUpsert,
|
||||||
|
) -> None:
|
||||||
|
server.name = body.name.strip()
|
||||||
|
server.description = body.description.strip()
|
||||||
|
server.transport = body.transport
|
||||||
|
server.url = body.url.strip()
|
||||||
|
server.config = {
|
||||||
|
"timeout_seconds": body.timeout_seconds,
|
||||||
|
"headers": body.headers,
|
||||||
|
}
|
||||||
|
server.secrets = merge_secrets(
|
||||||
|
{"headers": body.secret_headers},
|
||||||
|
server.secrets or {},
|
||||||
|
)
|
||||||
|
server.status = body.status
|
||||||
|
|
||||||
|
|
||||||
|
def _function_slug(value: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
|
||||||
|
if not slug or not slug[0].isalpha():
|
||||||
|
slug = f"tool_{slug}"
|
||||||
|
return slug[:64]
|
||||||
|
|
||||||
|
|
||||||
|
async def _available_function_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
server: McpServer,
|
||||||
|
remote_name: str,
|
||||||
|
) -> str:
|
||||||
|
base = _function_slug(f"mcp_{server.name}_{remote_name}")
|
||||||
|
candidate = base
|
||||||
|
suffix = 2
|
||||||
|
while (
|
||||||
|
await session.execute(
|
||||||
|
select(Tool.id).where(Tool.function_name == candidate).limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none():
|
||||||
|
tail = f"_{suffix}"
|
||||||
|
candidate = f"{base[: 64 - len(tail)]}{tail}"
|
||||||
|
suffix += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[McpServerOut])
|
||||||
|
async def list_mcp_servers(session: AsyncSession = Depends(get_session)):
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(McpServer).order_by(McpServer.updated_at.desc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [await _to_out(session, server) for server in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=McpServerOut)
|
||||||
|
async def create_mcp_server(
|
||||||
|
body: McpServerUpsert,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
server = McpServer(
|
||||||
|
id=f"mcp_{uuid.uuid4().hex[:12]}",
|
||||||
|
name=body.name.strip(),
|
||||||
|
url=body.url.strip(),
|
||||||
|
)
|
||||||
|
_apply_body(server, body)
|
||||||
|
session.add(server)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(server)
|
||||||
|
return await _to_out(session, server)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{server_id}", response_model=McpServerOut)
|
||||||
|
async def get_mcp_server(
|
||||||
|
server_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
server = await session.get(McpServer, server_id)
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "MCP Server 不存在")
|
||||||
|
return await _to_out(session, server)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{server_id}", response_model=McpServerOut)
|
||||||
|
async def update_mcp_server(
|
||||||
|
server_id: str,
|
||||||
|
body: McpServerUpsert,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
server = await session.get(McpServer, server_id)
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "MCP Server 不存在")
|
||||||
|
_apply_body(server, body)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(server)
|
||||||
|
return await _to_out(session, server)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{server_id}/sync", response_model=McpSyncResult)
|
||||||
|
async def sync_mcp_tools(
|
||||||
|
server_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
server = await session.get(McpServer, server_id)
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "MCP Server 不存在")
|
||||||
|
if server.status != "active":
|
||||||
|
raise HTTPException(400, "请先启用 MCP Server")
|
||||||
|
|
||||||
|
try:
|
||||||
|
discovered = await McpToolClient().list_tools(_runtime_server(server))
|
||||||
|
except McpClientError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
existing_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Tool).where(Tool.mcp_server_id == server.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
existing = {
|
||||||
|
str(tool.remote_tool_name): tool
|
||||||
|
for tool in existing_rows
|
||||||
|
if tool.remote_tool_name
|
||||||
|
}
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
synchronized: list[Tool] = []
|
||||||
|
|
||||||
|
for remote in discovered:
|
||||||
|
remote_name = str(remote["name"])
|
||||||
|
tool = existing.get(remote_name)
|
||||||
|
if tool is None:
|
||||||
|
tool = Tool(
|
||||||
|
id=f"tool_{uuid.uuid4().hex[:12]}",
|
||||||
|
name=f"{server.name} · {remote_name}",
|
||||||
|
function_name=await _available_function_name(
|
||||||
|
session,
|
||||||
|
server,
|
||||||
|
remote_name,
|
||||||
|
),
|
||||||
|
type="mcp",
|
||||||
|
mcp_server_id=server.id,
|
||||||
|
remote_tool_name=remote_name,
|
||||||
|
description=str(remote.get("description") or ""),
|
||||||
|
definition={},
|
||||||
|
secrets={},
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
session.add(tool)
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
previous_config = (tool.definition or {}).get("config") or {}
|
||||||
|
tool.description = str(remote.get("description") or tool.description)
|
||||||
|
tool.definition = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"type": "mcp",
|
||||||
|
"config": {
|
||||||
|
"remote_tool_name": remote_name,
|
||||||
|
"input_schema": remote.get("input_schema") or {},
|
||||||
|
"schema_hash": remote.get("schema_hash") or "",
|
||||||
|
"dynamic_variable_assignments": previous_config.get(
|
||||||
|
"dynamic_variable_assignments"
|
||||||
|
)
|
||||||
|
or {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
synchronized.append(tool)
|
||||||
|
|
||||||
|
server.last_synced_at = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(409, "同步工具时发生名称冲突,请重试") from exc
|
||||||
|
for tool in synchronized:
|
||||||
|
await session.refresh(tool)
|
||||||
|
await session.refresh(server)
|
||||||
|
server_out = await _to_out(session, server)
|
||||||
|
tool_outputs: list[ToolOut] = [tool_to_out(tool) for tool in synchronized]
|
||||||
|
return McpSyncResult(
|
||||||
|
server=server_out,
|
||||||
|
created=created,
|
||||||
|
updated=updated,
|
||||||
|
tools=tool_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{server_id}/tools", response_model=list[ToolOut])
|
||||||
|
async def list_mcp_tools(
|
||||||
|
server_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
if not await session.get(McpServer, server_id):
|
||||||
|
raise HTTPException(404, "MCP Server 不存在")
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Tool)
|
||||||
|
.where(Tool.mcp_server_id == server_id)
|
||||||
|
.order_by(Tool.name)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [tool_to_out(tool) for tool in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{server_id}")
|
||||||
|
async def delete_mcp_server(
|
||||||
|
server_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
server = await session.get(McpServer, server_id)
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(404, "MCP Server 不存在")
|
||||||
|
in_use = (
|
||||||
|
await session.execute(
|
||||||
|
select(AssistantToolBinding.tool_id)
|
||||||
|
.join(Tool, Tool.id == AssistantToolBinding.tool_id)
|
||||||
|
.where(Tool.mcp_server_id == server_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if in_use:
|
||||||
|
raise HTTPException(409, "MCP 工具正被助手引用,请先解绑")
|
||||||
|
await session.delete(server)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -7,7 +7,8 @@ from db.session import get_session
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from schemas import ToolOut, ToolUpsert
|
from schemas import ToolOut, ToolUpsert
|
||||||
from services.auth import require_admin
|
from services.auth import require_admin
|
||||||
from services.masking import mask_secrets, merge_secrets
|
from services.masking import merge_secrets
|
||||||
|
from services.tool_resources import tool_to_out
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -20,21 +21,10 @@ router = APIRouter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_out(tool: Tool) -> ToolOut:
|
def _payload(
|
||||||
return ToolOut(
|
body: ToolUpsert,
|
||||||
id=tool.id,
|
stored_secrets: dict | None = None,
|
||||||
name=tool.name,
|
) -> dict:
|
||||||
function_name=tool.function_name,
|
|
||||||
type=tool.type,
|
|
||||||
description=tool.description,
|
|
||||||
definition=tool.definition,
|
|
||||||
secrets=mask_secrets(tool.secrets or {}),
|
|
||||||
status=tool.status,
|
|
||||||
updated_at=tool.updated_at.isoformat() if tool.updated_at else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict:
|
|
||||||
definition = body.definition.model_dump()
|
definition = body.definition.model_dump()
|
||||||
secrets = (
|
secrets = (
|
||||||
merge_secrets(body.secrets, stored_secrets or {})
|
merge_secrets(body.secrets, stored_secrets or {})
|
||||||
@@ -45,6 +35,12 @@ def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict:
|
|||||||
"name": body.name.strip(),
|
"name": body.name.strip(),
|
||||||
"function_name": body.function_name,
|
"function_name": body.function_name,
|
||||||
"type": definition["type"],
|
"type": definition["type"],
|
||||||
|
"mcp_server_id": body.mcp_server_id if definition["type"] == "mcp" else None,
|
||||||
|
"remote_tool_name": (
|
||||||
|
definition["config"]["remote_tool_name"]
|
||||||
|
if definition["type"] == "mcp"
|
||||||
|
else None
|
||||||
|
),
|
||||||
"description": body.description.strip(),
|
"description": body.description.strip(),
|
||||||
"definition": definition,
|
"definition": definition,
|
||||||
"secrets": secrets,
|
"secrets": secrets,
|
||||||
@@ -66,7 +62,7 @@ async def _commit(session: AsyncSession, tool: Tool) -> ToolOut:
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise HTTPException(409, "工具函数名已存在") from exc
|
raise HTTPException(409, "工具函数名已存在") from exc
|
||||||
await session.refresh(tool)
|
await session.refresh(tool)
|
||||||
return _to_out(tool)
|
return tool_to_out(tool)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ToolOut])
|
@router.get("", response_model=list[ToolOut])
|
||||||
@@ -74,11 +70,13 @@ async def list_tools(session: AsyncSession = Depends(get_session)):
|
|||||||
rows = (
|
rows = (
|
||||||
await session.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
await session.execute(select(Tool).order_by(Tool.updated_at.desc()))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return [_to_out(tool) for tool in rows]
|
return [tool_to_out(tool) for tool in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ToolOut)
|
@router.post("", response_model=ToolOut)
|
||||||
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
|
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
|
||||||
|
if body.definition.type == "mcp":
|
||||||
|
raise HTTPException(400, "MCP 工具请通过 MCP Server 同步创建")
|
||||||
tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body))
|
tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body))
|
||||||
session.add(tool)
|
session.add(tool)
|
||||||
return await _commit(session, tool)
|
return await _commit(session, tool)
|
||||||
@@ -89,7 +87,7 @@ async def get_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
|
|||||||
tool = await session.get(Tool, tool_id)
|
tool = await session.get(Tool, tool_id)
|
||||||
if not tool:
|
if not tool:
|
||||||
raise HTTPException(404, "工具不存在")
|
raise HTTPException(404, "工具不存在")
|
||||||
return _to_out(tool)
|
return tool_to_out(tool)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{tool_id}", response_model=ToolOut)
|
@router.put("/{tool_id}", response_model=ToolOut)
|
||||||
@@ -101,6 +99,14 @@ async def update_tool(
|
|||||||
tool = await session.get(Tool, tool_id)
|
tool = await session.get(Tool, tool_id)
|
||||||
if not tool:
|
if not tool:
|
||||||
raise HTTPException(404, "工具不存在")
|
raise HTTPException(404, "工具不存在")
|
||||||
|
if tool.type == "mcp":
|
||||||
|
if body.definition.type != "mcp":
|
||||||
|
raise HTTPException(400, "同步生成的 MCP 工具不能修改类型")
|
||||||
|
if (
|
||||||
|
body.mcp_server_id != tool.mcp_server_id
|
||||||
|
or body.definition.config.remote_tool_name != tool.remote_tool_name
|
||||||
|
):
|
||||||
|
raise HTTPException(400, "MCP Server 和远端工具名称不能修改")
|
||||||
for key, value in _payload(body, tool.secrets or {}).items():
|
for key, value in _payload(body, tool.secrets or {}).items():
|
||||||
setattr(tool, key, value)
|
setattr(tool, key, value)
|
||||||
return await _commit(session, tool)
|
return await _commit(session, tool)
|
||||||
@@ -111,6 +117,8 @@ async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_sessi
|
|||||||
source = await session.get(Tool, tool_id)
|
source = await session.get(Tool, tool_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(404, "工具不存在")
|
raise HTTPException(404, "工具不存在")
|
||||||
|
if source.type == "mcp":
|
||||||
|
raise HTTPException(400, "MCP 工具由 Server 同步维护,不能单独复制")
|
||||||
function_name = _duplicate_function_name(source.function_name)
|
function_name = _duplicate_function_name(source.function_name)
|
||||||
existing = (
|
existing = (
|
||||||
await session.execute(select(Tool).where(Tool.function_name == function_name))
|
await session.execute(select(Tool).where(Tool.function_name == function_name))
|
||||||
@@ -124,6 +132,8 @@ async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_sessi
|
|||||||
name=f"{source.name} 副本",
|
name=f"{source.name} 副本",
|
||||||
function_name=function_name,
|
function_name=function_name,
|
||||||
type=source.type,
|
type=source.type,
|
||||||
|
mcp_server_id=source.mcp_server_id,
|
||||||
|
remote_tool_name=source.remote_tool_name,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
definition=dict(source.definition or {}),
|
definition=dict(source.definition or {}),
|
||||||
secrets=dict(source.secrets or {}),
|
secrets=dict(source.secrets or {}),
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
|
|||||||
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
|
||||||
TurnEndStrategy = Literal["silence", "smart_turn"]
|
TurnEndStrategy = Literal["silence", "smart_turn"]
|
||||||
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
||||||
ToolType = Literal["end_call", "http"]
|
ToolType = Literal["end_call", "http", "mcp"]
|
||||||
ToolStatus = Literal["active", "archived", "draft"]
|
ToolStatus = Literal["active", "archived", "draft"]
|
||||||
|
McpServerStatus = Literal["active", "archived", "draft"]
|
||||||
|
McpTransport = Literal["streamable_http"]
|
||||||
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
||||||
ToolParameterLocation = Literal["path", "query", "body", "header"]
|
ToolParameterLocation = Literal["path", "query", "body", "header"]
|
||||||
DynamicVariableType = Literal["string", "number", "boolean"]
|
DynamicVariableType = Literal["string", "number", "boolean"]
|
||||||
@@ -219,8 +221,22 @@ class HttpToolDefinition(CamelModel):
|
|||||||
config: HttpToolConfig
|
config: HttpToolConfig
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolConfig(CamelModel):
|
||||||
|
remote_tool_name: str = Field(min_length=1, max_length=255)
|
||||||
|
input_schema: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
schema_hash: str = ""
|
||||||
|
dynamic_variable_assignments: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolDefinition(CamelModel):
|
||||||
|
schema_version: int = 1
|
||||||
|
type: Literal["mcp"] = "mcp"
|
||||||
|
config: McpToolConfig
|
||||||
|
|
||||||
|
|
||||||
ToolDefinition = Annotated[
|
ToolDefinition = Annotated[
|
||||||
Union[EndCallToolDefinition, HttpToolDefinition], Field(discriminator="type")
|
Union[EndCallToolDefinition, HttpToolDefinition, McpToolDefinition],
|
||||||
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -231,14 +247,49 @@ class ToolUpsert(CamelModel):
|
|||||||
definition: ToolDefinition
|
definition: ToolDefinition
|
||||||
secrets: dict[str, Any] = Field(default_factory=dict)
|
secrets: dict[str, Any] = Field(default_factory=dict)
|
||||||
status: ToolStatus = "active"
|
status: ToolStatus = "active"
|
||||||
|
mcp_server_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ToolOut(ToolUpsert):
|
class ToolOut(ToolUpsert):
|
||||||
id: str
|
id: str
|
||||||
type: ToolType
|
type: ToolType
|
||||||
|
remote_tool_name: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- MCP Server ----------
|
||||||
|
class McpServerUpsert(CamelModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str = Field(default="", max_length=2048)
|
||||||
|
transport: McpTransport = "streamable_http"
|
||||||
|
url: str = Field(min_length=1, max_length=2048)
|
||||||
|
timeout_seconds: int = Field(default=30, ge=1, le=120)
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
secret_headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
status: McpServerStatus = "active"
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def validate_url(cls, value: str) -> str:
|
||||||
|
if not value.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("MCP Server URL 必须使用 http:// 或 https://")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerOut(McpServerUpsert):
|
||||||
|
id: str
|
||||||
|
tool_count: int = 0
|
||||||
|
last_synced_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class McpSyncResult(CamelModel):
|
||||||
|
server: McpServerOut
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
tools: list[ToolOut]
|
||||||
|
|
||||||
|
|
||||||
# ---------- 知识库 ----------
|
# ---------- 知识库 ----------
|
||||||
class KnowledgeBaseUpsert(CamelModel):
|
class KnowledgeBaseUpsert(CamelModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ class PromptBrain(BaseBrain):
|
|||||||
for tool in cfg.tools:
|
for tool in cfg.tools:
|
||||||
if tool.type == "end_call":
|
if tool.type == "end_call":
|
||||||
schema, handler = self._make_end_call_tool(tool, runtime)
|
schema, handler = self._make_end_call_tool(tool, runtime)
|
||||||
elif tool.type == "http":
|
elif tool.type in {"http", "mcp"}:
|
||||||
schema, handler = self._make_http_tool(tool, runtime)
|
schema, handler = self._make_remote_tool(tool, runtime)
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
schemas.append(schema)
|
schemas.append(schema)
|
||||||
@@ -96,7 +96,7 @@ class PromptBrain(BaseBrain):
|
|||||||
if self._dynamic_enabled and self._runtime is not None:
|
if self._dynamic_enabled and self._runtime is not None:
|
||||||
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
|
||||||
|
|
||||||
def _make_http_tool(self, tool, runtime: BrainRuntime):
|
def _make_remote_tool(self, tool, runtime: BrainRuntime):
|
||||||
properties, required = self._tools.schema_parts(tool)
|
properties, required = self._tools.schema_parts(tool)
|
||||||
self._tools.register_secrets(tool)
|
self._tools.register_secrets(tool)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class PromptBrain(BaseBrain):
|
|||||||
await params.result_callback(result)
|
await params.result_callback(result)
|
||||||
except (ToolExecutionError, ValueError) as exc:
|
except (ToolExecutionError, ValueError) as exc:
|
||||||
await params.result_callback(
|
await params.result_callback(
|
||||||
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
|
{"status": "error", "message": f"工具调用失败: {exc}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
schema = FunctionSchema(
|
schema = FunctionSchema(
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
functions: list[FlowsFunctionSchema] = []
|
functions: list[FlowsFunctionSchema] = []
|
||||||
for tool_id in stage.tool_ids:
|
for tool_id in stage.tool_ids:
|
||||||
tool = self._tool_by_id.get(str(tool_id))
|
tool = self._tool_by_id.get(str(tool_id))
|
||||||
if tool and tool.type == "http":
|
if tool and tool.type in {"http", "mcp"}:
|
||||||
functions.append(self._flow_tool(tool, node_id))
|
functions.append(self._flow_tool(tool, node_id))
|
||||||
knowledge_function = self._knowledge_function(node_id)
|
knowledge_function = self._knowledge_function(node_id)
|
||||||
if knowledge_function:
|
if knowledge_function:
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ from db.models import (
|
|||||||
AssistantModelBinding,
|
AssistantModelBinding,
|
||||||
AssistantToolBinding,
|
AssistantToolBinding,
|
||||||
KnowledgeBase,
|
KnowledgeBase,
|
||||||
|
McpServer,
|
||||||
ModelResource,
|
ModelResource,
|
||||||
Tool,
|
Tool,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
AssistantConfig,
|
AssistantConfig,
|
||||||
RuntimeKnowledgeBase,
|
RuntimeKnowledgeBase,
|
||||||
|
RuntimeMcpServer,
|
||||||
RuntimeModelResource,
|
RuntimeModelResource,
|
||||||
RuntimeTool,
|
RuntimeTool,
|
||||||
)
|
)
|
||||||
@@ -102,18 +104,55 @@ async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[Runtim
|
|||||||
.order_by(AssistantToolBinding.created_at, Tool.id)
|
.order_by(AssistantToolBinding.created_at, Tool.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return [
|
server_ids = {
|
||||||
RuntimeTool(
|
str(tool.mcp_server_id)
|
||||||
id=tool.id,
|
|
||||||
name=tool.name,
|
|
||||||
function_name=tool.function_name,
|
|
||||||
type=tool.type,
|
|
||||||
description=tool.description,
|
|
||||||
definition=tool.definition or {},
|
|
||||||
secrets=tool.secrets or {},
|
|
||||||
)
|
|
||||||
for tool in tools
|
for tool in tools
|
||||||
]
|
if tool.type == "mcp" and tool.mcp_server_id
|
||||||
|
}
|
||||||
|
server_rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(McpServer).where(
|
||||||
|
McpServer.id.in_(server_ids),
|
||||||
|
McpServer.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if server_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
servers = {server.id: server for server in server_rows}
|
||||||
|
resolved: list[RuntimeTool] = []
|
||||||
|
for tool in tools:
|
||||||
|
mcp_server = None
|
||||||
|
if tool.type == "mcp":
|
||||||
|
server = servers.get(str(tool.mcp_server_id or ""))
|
||||||
|
if server is None:
|
||||||
|
continue
|
||||||
|
config = server.config or {}
|
||||||
|
secrets = server.secrets or {}
|
||||||
|
mcp_server = RuntimeMcpServer(
|
||||||
|
id=server.id,
|
||||||
|
name=server.name,
|
||||||
|
transport=server.transport,
|
||||||
|
url=server.url,
|
||||||
|
timeout_seconds=int(config.get("timeout_seconds") or 30),
|
||||||
|
headers=dict(config.get("headers") or {}),
|
||||||
|
secret_headers=dict(secrets.get("headers") or {}),
|
||||||
|
)
|
||||||
|
resolved.append(
|
||||||
|
RuntimeTool(
|
||||||
|
id=tool.id,
|
||||||
|
name=tool.name,
|
||||||
|
function_name=tool.function_name,
|
||||||
|
type=tool.type,
|
||||||
|
description=tool.description,
|
||||||
|
definition=tool.definition or {},
|
||||||
|
secrets=tool.secrets or {},
|
||||||
|
mcp_server=mcp_server,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
async def resolve_runtime_config(
|
async def resolve_runtime_config(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from services.runtime_variables import (
|
|||||||
DynamicVariableStore,
|
DynamicVariableStore,
|
||||||
value_at_path,
|
value_at_path,
|
||||||
)
|
)
|
||||||
|
from services.tools import McpClientError, McpToolClient
|
||||||
|
|
||||||
|
|
||||||
class ToolExecutionError(RuntimeError):
|
class ToolExecutionError(RuntimeError):
|
||||||
@@ -21,8 +22,16 @@ class ToolExecutionError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class ToolExecutor:
|
class ToolExecutor:
|
||||||
def __init__(self, store: DynamicVariableStore):
|
"""Execute supported tools and apply their results to session variables."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: DynamicVariableStore,
|
||||||
|
*,
|
||||||
|
mcp_client: McpToolClient | None = None,
|
||||||
|
):
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self._mcp_client = mcp_client or McpToolClient()
|
||||||
|
|
||||||
def register_secrets(self, tool: RuntimeTool) -> None:
|
def register_secrets(self, tool: RuntimeTool) -> None:
|
||||||
dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
|
dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
|
||||||
@@ -34,6 +43,12 @@ class ToolExecutor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def schema_parts(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
|
def schema_parts(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
|
||||||
config = (tool.definition or {}).get("config") or {}
|
config = (tool.definition or {}).get("config") or {}
|
||||||
|
if tool.type == "mcp":
|
||||||
|
schema = config.get("input_schema") or {}
|
||||||
|
return (
|
||||||
|
dict(schema.get("properties") or {}),
|
||||||
|
[str(name) for name in schema.get("required") or []],
|
||||||
|
)
|
||||||
parameters = list(config.get("parameters") or [])
|
parameters = list(config.get("parameters") or [])
|
||||||
properties = {
|
properties = {
|
||||||
str(parameter.get("name")): {
|
str(parameter.get("name")): {
|
||||||
@@ -58,11 +73,16 @@ class ToolExecutor:
|
|||||||
result_assignments: dict[str, str] | None = None,
|
result_assignments: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self.register_secrets(tool)
|
self.register_secrets(tool)
|
||||||
if tool.type != "http":
|
normalized_arguments = dict(arguments or {})
|
||||||
raise ToolExecutionError(f"Action 暂不支持工具类型: {tool.type}")
|
if tool.type == "http":
|
||||||
return await self._execute_http(
|
result = await self._execute_http(tool, normalized_arguments)
|
||||||
|
elif tool.type == "mcp":
|
||||||
|
result = await self._execute_mcp(tool, normalized_arguments)
|
||||||
|
else:
|
||||||
|
raise ToolExecutionError(f"不支持工具类型: {tool.type}")
|
||||||
|
return self._apply_result_assignments(
|
||||||
tool,
|
tool,
|
||||||
dict(arguments or {}),
|
result,
|
||||||
result_assignments=result_assignments,
|
result_assignments=result_assignments,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,8 +90,6 @@ class ToolExecutor:
|
|||||||
self,
|
self,
|
||||||
tool: RuntimeTool,
|
tool: RuntimeTool,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
*,
|
|
||||||
result_assignments: dict[str, str] | None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = (tool.definition or {}).get("config") or {}
|
config = (tool.definition or {}).get("config") or {}
|
||||||
parameters = list(config.get("parameters") or [])
|
parameters = list(config.get("parameters") or [])
|
||||||
@@ -130,11 +148,59 @@ class ToolExecutor:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
payload = {"text": response.text[:8000]}
|
payload = {"text": response.text[:8000]}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"data": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _execute_mcp(
|
||||||
|
self,
|
||||||
|
tool: RuntimeTool,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
server = tool.mcp_server
|
||||||
|
if server is None:
|
||||||
|
raise ToolExecutionError("MCP 工具缺少可用的 Server 连接")
|
||||||
|
config = (tool.definition or {}).get("config") or {}
|
||||||
|
remote_tool_name = str(config.get("remote_tool_name") or "")
|
||||||
|
if not remote_tool_name:
|
||||||
|
raise ToolExecutionError("MCP 工具缺少远端工具名称")
|
||||||
|
|
||||||
|
resolved_server = server.model_copy(
|
||||||
|
update={
|
||||||
|
"url": self.store.render(server.url),
|
||||||
|
"headers": self.store.render_data(server.headers),
|
||||||
|
"secret_headers": self.store.render_data(
|
||||||
|
server.secret_headers,
|
||||||
|
allow_secrets=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = await self._mcp_client.call_tool(
|
||||||
|
resolved_server,
|
||||||
|
remote_tool_name,
|
||||||
|
arguments,
|
||||||
|
)
|
||||||
|
except McpClientError as exc:
|
||||||
|
raise ToolExecutionError(str(exc)) from exc
|
||||||
|
return {"status": "ok", "data": payload}
|
||||||
|
|
||||||
|
def _apply_result_assignments(
|
||||||
|
self,
|
||||||
|
tool: RuntimeTool,
|
||||||
|
result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
result_assignments: dict[str, str] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = (tool.definition or {}).get("config") or {}
|
||||||
assignments = (
|
assignments = (
|
||||||
result_assignments
|
result_assignments
|
||||||
if result_assignments is not None
|
if result_assignments is not None
|
||||||
else config.get("dynamic_variable_assignments") or {}
|
else config.get("dynamic_variable_assignments") or {}
|
||||||
)
|
)
|
||||||
|
payload = result.get("data")
|
||||||
updated: list[str] = []
|
updated: list[str] = []
|
||||||
for variable_name, path in assignments.items():
|
for variable_name, path in assignments.items():
|
||||||
try:
|
try:
|
||||||
@@ -146,9 +212,4 @@ class ToolExecutor:
|
|||||||
continue
|
continue
|
||||||
self.store.assign(str(variable_name), value)
|
self.store.assign(str(variable_name), value)
|
||||||
updated.append(str(variable_name))
|
updated.append(str(variable_name))
|
||||||
return {
|
return {**result, "updated_variables": updated}
|
||||||
"status": "ok",
|
|
||||||
"status_code": response.status_code,
|
|
||||||
"data": payload,
|
|
||||||
"updated_variables": updated,
|
|
||||||
}
|
|
||||||
|
|||||||
21
backend/services/tool_resources.py
Normal file
21
backend/services/tool_resources.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Shared serialization helpers for reusable tool resources."""
|
||||||
|
|
||||||
|
from db.models import Tool
|
||||||
|
from schemas import ToolOut
|
||||||
|
from services.masking import mask_secrets
|
||||||
|
|
||||||
|
|
||||||
|
def tool_to_out(tool: Tool) -> ToolOut:
|
||||||
|
return ToolOut(
|
||||||
|
id=tool.id,
|
||||||
|
name=tool.name,
|
||||||
|
function_name=tool.function_name,
|
||||||
|
type=tool.type, # type: ignore[arg-type]
|
||||||
|
description=tool.description,
|
||||||
|
definition=tool.definition,
|
||||||
|
secrets=mask_secrets(tool.secrets or {}),
|
||||||
|
status=tool.status, # type: ignore[arg-type]
|
||||||
|
mcp_server_id=tool.mcp_server_id,
|
||||||
|
remote_tool_name=tool.remote_tool_name,
|
||||||
|
updated_at=tool.updated_at.isoformat() if tool.updated_at else None,
|
||||||
|
)
|
||||||
5
backend/services/tools/__init__.py
Normal file
5
backend/services/tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Small protocol adapters used by the shared ToolExecutor."""
|
||||||
|
|
||||||
|
from services.tools.mcp_client import McpClientError, McpToolClient
|
||||||
|
|
||||||
|
__all__ = ["McpClientError", "McpToolClient"]
|
||||||
152
backend/services/tools/mcp_client.py
Normal file
152
backend/services/tools/mcp_client.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""One-shot Streamable HTTP MCP discovery and execution.
|
||||||
|
|
||||||
|
Connections intentionally live for one operation in the MVP. This keeps the
|
||||||
|
async context ownership simple and correct; a session pool can be introduced
|
||||||
|
later if measurements show MCP handshakes are a meaningful source of latency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp import ClientSession
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
|
from models import RuntimeMcpServer
|
||||||
|
|
||||||
|
|
||||||
|
MAX_MCP_RESULT_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class McpClientError(RuntimeError):
|
||||||
|
"""Safe error surfaced to tool callers and the administration API."""
|
||||||
|
|
||||||
|
|
||||||
|
def schema_hash(input_schema: dict[str, Any]) -> str:
|
||||||
|
serialized = json.dumps(
|
||||||
|
input_schema,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolClient:
|
||||||
|
"""Minimal public MCP SDK wrapper shared by discovery and runtime calls."""
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _session(
|
||||||
|
self,
|
||||||
|
server: RuntimeMcpServer,
|
||||||
|
) -> AsyncIterator[ClientSession]:
|
||||||
|
if server.transport != "streamable_http":
|
||||||
|
raise McpClientError(f"暂不支持 MCP transport: {server.transport}")
|
||||||
|
|
||||||
|
headers = {**server.headers, **server.secret_headers}
|
||||||
|
timeout = max(1, min(int(server.timeout_seconds), 120))
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
headers=headers,
|
||||||
|
timeout=httpx.Timeout(timeout),
|
||||||
|
follow_redirects=False,
|
||||||
|
) as http_client:
|
||||||
|
async with streamable_http_client(
|
||||||
|
server.url,
|
||||||
|
http_client=http_client,
|
||||||
|
) as streams:
|
||||||
|
read_stream, write_stream, _ = streams
|
||||||
|
async with ClientSession(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
read_timeout_seconds=timedelta(seconds=timeout),
|
||||||
|
) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
except McpClientError:
|
||||||
|
raise
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise McpClientError("MCP Server 连接或调用超时") from exc
|
||||||
|
except Exception as exc: # MCP SDK exposes several transport exceptions.
|
||||||
|
raise McpClientError(f"MCP Server 调用失败: {exc}") from exc
|
||||||
|
|
||||||
|
async def list_tools(self, server: RuntimeMcpServer) -> list[dict[str, Any]]:
|
||||||
|
"""Discover all remote tools, following MCP pagination cursors."""
|
||||||
|
discovered: list[dict[str, Any]] = []
|
||||||
|
async with self._session(server) as session:
|
||||||
|
cursor: str | None = None
|
||||||
|
while True:
|
||||||
|
result = await session.list_tools(cursor=cursor)
|
||||||
|
for tool in result.tools:
|
||||||
|
input_schema = dict(tool.inputSchema or {})
|
||||||
|
discovered.append(
|
||||||
|
{
|
||||||
|
"name": tool.name,
|
||||||
|
"description": tool.description or "",
|
||||||
|
"input_schema": input_schema,
|
||||||
|
"schema_hash": schema_hash(input_schema),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cursor = result.nextCursor
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
async def call_tool(
|
||||||
|
self,
|
||||||
|
server: RuntimeMcpServer,
|
||||||
|
remote_tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute one remote tool and normalize the result for local callers."""
|
||||||
|
async with self._session(server) as session:
|
||||||
|
result = await session.call_tool(
|
||||||
|
remote_tool_name,
|
||||||
|
arguments=arguments,
|
||||||
|
read_timeout_seconds=timedelta(seconds=server.timeout_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = self._normalize_result(result)
|
||||||
|
encoded = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
|
||||||
|
if len(encoded) > MAX_MCP_RESULT_BYTES:
|
||||||
|
raise McpClientError("MCP 工具响应超过 1 MB 限制")
|
||||||
|
if bool(result.isError):
|
||||||
|
message = data.get("text") or "MCP 工具返回执行错误"
|
||||||
|
raise McpClientError(str(message)[:2048])
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_result(result: Any) -> dict[str, Any]:
|
||||||
|
"""Keep useful text/JSON while deliberately omitting binary payloads."""
|
||||||
|
content: list[dict[str, Any]] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
for item in result.content or []:
|
||||||
|
item_type = str(getattr(item, "type", "unknown"))
|
||||||
|
if item_type == "text":
|
||||||
|
text = str(getattr(item, "text", ""))
|
||||||
|
content.append({"type": "text", "text": text})
|
||||||
|
if text:
|
||||||
|
text_parts.append(text)
|
||||||
|
continue
|
||||||
|
if item_type == "resource":
|
||||||
|
resource = getattr(item, "resource", None)
|
||||||
|
text = getattr(resource, "text", None)
|
||||||
|
if isinstance(text, str):
|
||||||
|
content.append({"type": "resource", "text": text})
|
||||||
|
text_parts.append(text)
|
||||||
|
else:
|
||||||
|
content.append({"type": "resource", "omitted": True})
|
||||||
|
continue
|
||||||
|
# Audio and image base64 data are not sent into the LLM context in MVP.
|
||||||
|
content.append({"type": item_type, "omitted": True})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": "\n".join(text_parts),
|
||||||
|
"structuredContent": result.structuredContent,
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
105
backend/tests/test_tool_executor_mcp.py
Normal file
105
backend/tests/test_tool_executor_mcp.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from models import RuntimeMcpServer, RuntimeTool
|
||||||
|
from services.runtime_variables import DynamicVariableStore
|
||||||
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
||||||
|
from services.tools import McpClientError
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_tool() -> RuntimeTool:
|
||||||
|
return RuntimeTool(
|
||||||
|
id="tool_mcp_order",
|
||||||
|
name="查询订单",
|
||||||
|
function_name="query_order",
|
||||||
|
type="mcp",
|
||||||
|
description="查询订单状态",
|
||||||
|
definition={
|
||||||
|
"schema_version": 1,
|
||||||
|
"type": "mcp",
|
||||||
|
"config": {
|
||||||
|
"remote_tool_name": "get_order",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"order_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "订单编号",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["order_id"],
|
||||||
|
},
|
||||||
|
"dynamic_variable_assignments": {
|
||||||
|
"order_status": "structuredContent.status"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mcp_server=RuntimeMcpServer(
|
||||||
|
id="mcp_orders",
|
||||||
|
name="订单服务",
|
||||||
|
url="https://mcp.example.com/{{tenant}}",
|
||||||
|
headers={"X-Tenant": "{{tenant}}"},
|
||||||
|
secret_headers={"Authorization": "Bearer secret"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMcpClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.error: Exception | None = None
|
||||||
|
|
||||||
|
async def call_tool(self, server, remote_tool_name, arguments):
|
||||||
|
self.calls.append((server, remote_tool_name, arguments))
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return {
|
||||||
|
"text": "订单已发货",
|
||||||
|
"structuredContent": {"status": "shipped"},
|
||||||
|
"content": [{"type": "text", "text": "订单已发货"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ToolExecutorMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_mcp_schema_is_exposed_to_llm(self):
|
||||||
|
properties, required = ToolExecutor.schema_parts(mcp_tool())
|
||||||
|
|
||||||
|
self.assertEqual(properties["order_id"]["type"], "string")
|
||||||
|
self.assertEqual(required, ["order_id"])
|
||||||
|
|
||||||
|
async def test_mcp_execution_renders_connection_and_assigns_result(self):
|
||||||
|
store = DynamicVariableStore(
|
||||||
|
{"tenant": "school-a"},
|
||||||
|
variable_types={"order_status": "string"},
|
||||||
|
)
|
||||||
|
client = FakeMcpClient()
|
||||||
|
executor = ToolExecutor(store, mcp_client=client)
|
||||||
|
|
||||||
|
result = await executor.execute(
|
||||||
|
mcp_tool(),
|
||||||
|
{"order_id": "A-100"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "ok")
|
||||||
|
self.assertEqual(result["updated_variables"], ["order_status"])
|
||||||
|
self.assertEqual(store.values["order_status"], "shipped")
|
||||||
|
server, remote_name, arguments = client.calls[0]
|
||||||
|
self.assertEqual(server.url, "https://mcp.example.com/school-a")
|
||||||
|
self.assertEqual(server.headers["X-Tenant"], "school-a")
|
||||||
|
self.assertEqual(server.secret_headers["Authorization"], "Bearer secret")
|
||||||
|
self.assertEqual(remote_name, "get_order")
|
||||||
|
self.assertEqual(arguments, {"order_id": "A-100"})
|
||||||
|
|
||||||
|
async def test_mcp_client_errors_use_shared_tool_error(self):
|
||||||
|
client = FakeMcpClient()
|
||||||
|
client.error = McpClientError("远端不可用")
|
||||||
|
executor = ToolExecutor(
|
||||||
|
DynamicVariableStore({"tenant": "school-a"}),
|
||||||
|
mcp_client=client,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ToolExecutionError, "远端不可用"):
|
||||||
|
await executor.execute(mcp_tool(), {"order_id": "A-100"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
PhoneOff,
|
PhoneOff,
|
||||||
Plus,
|
Plus,
|
||||||
|
ServerCog,
|
||||||
Settings2,
|
Settings2,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -472,7 +473,13 @@ export function ToolPicker({
|
|||||||
key={tool.id}
|
key={tool.id}
|
||||||
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
||||||
>
|
>
|
||||||
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
|
{tool.type === "end_call" ? (
|
||||||
|
<PhoneOff size={14} />
|
||||||
|
) : tool.type === "mcp" ? (
|
||||||
|
<ServerCog size={14} />
|
||||||
|
) : (
|
||||||
|
<Wrench size={14} />
|
||||||
|
)}
|
||||||
<span className="max-w-48 truncate">{tool.name}</span>
|
<span className="max-w-48 truncate">{tool.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -535,7 +542,11 @@ export function ToolPicker({
|
|||||||
{tool.name}
|
{tool.name}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
{tool.type === "end_call"
|
||||||
|
? "End Call"
|
||||||
|
: tool.type === "mcp"
|
||||||
|
? "MCP"
|
||||||
|
: "HTTP"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { McpServersSection } from "@/components/tools/McpServersSection";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||||
@@ -52,12 +53,12 @@ import {
|
|||||||
type ToolUpsert,
|
type ToolUpsert,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
type ToolKind = "end_call" | "http";
|
type ToolKind = "end_call" | "http" | "mcp";
|
||||||
type HttpMethod = HttpToolDefinition["config"]["method"];
|
type HttpMethod = HttpToolDefinition["config"]["method"];
|
||||||
type ToolFilter = "全部" | "End Call" | "HTTP";
|
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
|
||||||
type SortOrder = "newest" | "oldest";
|
type SortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP"];
|
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
|
||||||
|
|
||||||
type ToolForm = {
|
type ToolForm = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -77,6 +78,10 @@ type ToolForm = {
|
|||||||
body: string;
|
body: string;
|
||||||
dynamicVariableAssignments: string;
|
dynamicVariableAssignments: string;
|
||||||
secretDynamicVariables: string;
|
secretDynamicVariables: string;
|
||||||
|
mcpServerId: string;
|
||||||
|
remoteToolName: string;
|
||||||
|
inputSchema: string;
|
||||||
|
schemaHash: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_OBJECT = "{}";
|
const EMPTY_OBJECT = "{}";
|
||||||
@@ -101,6 +106,10 @@ function blankForm(): ToolForm {
|
|||||||
body: EMPTY_OBJECT,
|
body: EMPTY_OBJECT,
|
||||||
dynamicVariableAssignments: EMPTY_OBJECT,
|
dynamicVariableAssignments: EMPTY_OBJECT,
|
||||||
secretDynamicVariables: EMPTY_OBJECT,
|
secretDynamicVariables: EMPTY_OBJECT,
|
||||||
|
mcpServerId: "",
|
||||||
|
remoteToolName: "",
|
||||||
|
inputSchema: EMPTY_OBJECT,
|
||||||
|
schemaHash: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +128,17 @@ function formFromTool(tool: Tool): ToolForm {
|
|||||||
base.captureReason = tool.definition.config.captureReason;
|
base.captureReason = tool.definition.config.captureReason;
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
if (tool.definition.type === "mcp") {
|
||||||
|
base.mcpServerId = tool.mcpServerId ?? "";
|
||||||
|
base.remoteToolName = tool.definition.config.remoteToolName;
|
||||||
|
base.inputSchema = pretty(tool.definition.config.inputSchema, EMPTY_OBJECT);
|
||||||
|
base.schemaHash = tool.definition.config.schemaHash;
|
||||||
|
base.dynamicVariableAssignments = pretty(
|
||||||
|
tool.definition.config.dynamicVariableAssignments ?? {},
|
||||||
|
EMPTY_OBJECT,
|
||||||
|
);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
base.method = tool.definition.config.method;
|
base.method = tool.definition.config.method;
|
||||||
base.url = tool.definition.config.url;
|
base.url = tool.definition.config.url;
|
||||||
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
|
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
|
||||||
@@ -198,6 +218,29 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (form.type === "mcp") {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
functionName: form.functionName,
|
||||||
|
description: form.description.trim(),
|
||||||
|
status: form.status,
|
||||||
|
mcpServerId: form.mcpServerId,
|
||||||
|
secrets: {},
|
||||||
|
definition: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "mcp",
|
||||||
|
config: {
|
||||||
|
remoteToolName: form.remoteToolName,
|
||||||
|
inputSchema: parseObject(form.inputSchema, "MCP Input Schema"),
|
||||||
|
schemaHash: form.schemaHash,
|
||||||
|
dynamicVariableAssignments: parseObject(
|
||||||
|
form.dynamicVariableAssignments,
|
||||||
|
"变量赋值",
|
||||||
|
) as Record<string, string>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutSeconds = Number(form.timeoutSeconds);
|
const timeoutSeconds = Number(form.timeoutSeconds);
|
||||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
|
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
|
||||||
@@ -295,7 +338,8 @@ export function ComponentsToolsPage() {
|
|||||||
return tools.filter((tool) =>
|
return tools.filter((tool) =>
|
||||||
(filter === "全部" ||
|
(filter === "全部" ||
|
||||||
(filter === "End Call" && tool.type === "end_call") ||
|
(filter === "End Call" && tool.type === "end_call") ||
|
||||||
(filter === "HTTP" && tool.type === "http")) &&
|
(filter === "HTTP" && tool.type === "http") ||
|
||||||
|
(filter === "MCP" && tool.type === "mcp")) &&
|
||||||
(!query ||
|
(!query ||
|
||||||
[tool.name, tool.functionName, tool.description].some((value) =>
|
[tool.name, tool.functionName, tool.description].some((value) =>
|
||||||
value.toLowerCase().includes(query),
|
value.toLowerCase().includes(query),
|
||||||
@@ -411,7 +455,11 @@ export function ComponentsToolsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
{tool.type === "end_call"
|
||||||
|
? "End Call"
|
||||||
|
: tool.type === "mcp"
|
||||||
|
? "MCP"
|
||||||
|
: "HTTP"}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -478,7 +526,7 @@ export function ComponentsToolsPage() {
|
|||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
disabled={duplicatingId === tool.id}
|
disabled={duplicatingId === tool.id || tool.type === "mcp"}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void duplicateTool(tool.id);
|
void duplicateTool(tool.id);
|
||||||
@@ -527,6 +575,8 @@ export function ComponentsToolsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<McpServersSection onToolsChanged={loadTools} />
|
||||||
|
|
||||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||||
<ListToolbar
|
<ListToolbar
|
||||||
filters={
|
filters={
|
||||||
@@ -607,6 +657,7 @@ export function ComponentsToolsPage() {
|
|||||||
<Field label="工具类型" required>
|
<Field label="工具类型" required>
|
||||||
<Select
|
<Select
|
||||||
value={form.type}
|
value={form.type}
|
||||||
|
disabled={form.type === "mcp"}
|
||||||
onValueChange={(type: ToolKind) =>
|
onValueChange={(type: ToolKind) =>
|
||||||
setForm((current) => ({ ...current, type }))
|
setForm((current) => ({ ...current, type }))
|
||||||
}
|
}
|
||||||
@@ -615,6 +666,9 @@ export function ComponentsToolsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="end_call">End Call</SelectItem>
|
<SelectItem value="end_call">End Call</SelectItem>
|
||||||
<SelectItem value="http">HTTP</SelectItem>
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
|
<SelectItem value="mcp" disabled={!editing || form.type !== "mcp"}>
|
||||||
|
MCP(由 Server 同步)
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -647,6 +701,8 @@ export function ComponentsToolsPage() {
|
|||||||
<FieldSection title="参数配置" scrollable tall>
|
<FieldSection title="参数配置" scrollable tall>
|
||||||
{form.type === "end_call" ? (
|
{form.type === "end_call" ? (
|
||||||
<EndCallFields form={form} setForm={setForm} />
|
<EndCallFields form={form} setForm={setForm} />
|
||||||
|
) : form.type === "mcp" ? (
|
||||||
|
<McpFields form={form} setForm={setForm} />
|
||||||
) : (
|
) : (
|
||||||
<HttpFields form={form} setForm={setForm} />
|
<HttpFields form={form} setForm={setForm} />
|
||||||
)}
|
)}
|
||||||
@@ -720,6 +776,42 @@ function EndCallFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function McpFields({
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
}: {
|
||||||
|
form: ToolForm;
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
连接和参数 Schema 由 MCP Server 同步维护;这里仅配置本地名称、状态和结果变量赋值。
|
||||||
|
</div>
|
||||||
|
<Field label="远端工具名称">
|
||||||
|
<Input value={form.remoteToolName} disabled className="font-mono" />
|
||||||
|
</Field>
|
||||||
|
<Field label="MCP Server ID">
|
||||||
|
<Input value={form.mcpServerId} disabled className="font-mono" />
|
||||||
|
</Field>
|
||||||
|
<JsonField
|
||||||
|
label="Input Schema(只读)"
|
||||||
|
value={form.inputSchema}
|
||||||
|
onChange={() => undefined}
|
||||||
|
rows={10}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<JsonField
|
||||||
|
label="响应变量赋值"
|
||||||
|
value={form.dynamicVariableAssignments}
|
||||||
|
onChange={(dynamicVariableAssignments) =>
|
||||||
|
setForm((current) => ({ ...current, dynamicVariableAssignments }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HttpFields({
|
function HttpFields({
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
@@ -845,11 +937,13 @@ function JsonField({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Field label={label}>
|
<Field label={label}>
|
||||||
@@ -857,6 +951,7 @@ function JsonField({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
disabled={disabled}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
449
frontend/src/components/tools/McpServersSection.tsx
Normal file
449
frontend/src/components/tools/McpServersSection.tsx
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
ServerCog,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
mcpServersApi,
|
||||||
|
type McpServer,
|
||||||
|
type McpServerUpsert,
|
||||||
|
type ToolStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
type McpServerForm = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
timeoutSeconds: string;
|
||||||
|
headers: string;
|
||||||
|
secretHeaders: string;
|
||||||
|
status: ToolStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_OBJECT = "{}";
|
||||||
|
|
||||||
|
function blankForm(): McpServerForm {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
url: "",
|
||||||
|
timeoutSeconds: "30",
|
||||||
|
headers: EMPTY_OBJECT,
|
||||||
|
secretHeaders: EMPTY_OBJECT,
|
||||||
|
status: "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formFromServer(server: McpServer): McpServerForm {
|
||||||
|
return {
|
||||||
|
name: server.name,
|
||||||
|
description: server.description,
|
||||||
|
url: server.url,
|
||||||
|
timeoutSeconds: String(server.timeoutSeconds),
|
||||||
|
headers: JSON.stringify(server.headers ?? {}, null, 2),
|
||||||
|
secretHeaders: JSON.stringify(server.secretHeaders ?? {}, null, 2),
|
||||||
|
status: server.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringMap(value: string, label: string): Record<string, string> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value || EMPTY_OBJECT);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label}不是有效的 JSON`);
|
||||||
|
}
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||||
|
throw new Error(`${label}必须是 JSON 对象`);
|
||||||
|
}
|
||||||
|
const entries = Object.entries(parsed);
|
||||||
|
if (entries.some(([, item]) => typeof item !== "string")) {
|
||||||
|
throw new Error(`${label}的值必须全部是字符串`);
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries) as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form: McpServerForm): McpServerUpsert {
|
||||||
|
if (!form.name.trim()) throw new Error("请输入 MCP Server 名称");
|
||||||
|
if (!form.url.startsWith("http://") && !form.url.startsWith("https://")) {
|
||||||
|
throw new Error("MCP Server URL 必须使用 http:// 或 https://");
|
||||||
|
}
|
||||||
|
const timeoutSeconds = Number(form.timeoutSeconds);
|
||||||
|
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
|
||||||
|
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
transport: "streamable_http",
|
||||||
|
url: form.url.trim(),
|
||||||
|
timeoutSeconds,
|
||||||
|
headers: parseStringMap(form.headers, "Header"),
|
||||||
|
secretHeaders: parseStringMap(form.secretHeaders, "敏感 Header"),
|
||||||
|
status: form.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpServersSection({
|
||||||
|
onToolsChanged,
|
||||||
|
}: {
|
||||||
|
onToolsChanged: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [servers, setServers] = useState<McpServer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<McpServer | null>(null);
|
||||||
|
const [form, setForm] = useState<McpServerForm>(blankForm);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadServers = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setServers(await mcpServersApi.list());
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : "加载 MCP Server 失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void loadServers();
|
||||||
|
}, [loadServers]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(blankForm());
|
||||||
|
setFormError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(server: McpServer) {
|
||||||
|
setEditing(server);
|
||||||
|
setForm(formFromServer(server));
|
||||||
|
setFormError(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncServer(serverId: string) {
|
||||||
|
setSyncingId(serverId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await mcpServersApi.sync(serverId);
|
||||||
|
await Promise.all([loadServers(), onToolsChanged()]);
|
||||||
|
} catch (syncError) {
|
||||||
|
setError(syncError instanceof Error ? syncError.message : "同步 MCP 工具失败");
|
||||||
|
} finally {
|
||||||
|
setSyncingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndSync() {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const payload = payloadFromForm(form);
|
||||||
|
const saved = editing
|
||||||
|
? await mcpServersApi.update(editing.id, payload)
|
||||||
|
: await mcpServersApi.create(payload);
|
||||||
|
await mcpServersApi.sync(saved.id);
|
||||||
|
setDialogOpen(false);
|
||||||
|
await Promise.all([loadServers(), onToolsChanged()]);
|
||||||
|
} catch (saveError) {
|
||||||
|
setFormError(
|
||||||
|
saveError instanceof Error ? saveError.message : "保存或同步 MCP Server 失败",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeServer(server: McpServer) {
|
||||||
|
if (!window.confirm(`确认删除 MCP Server“${server.name}”及其同步工具?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeletingId(server.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await mcpServersApi.remove(server.id);
|
||||||
|
await Promise.all([loadServers(), onToolsChanged()]);
|
||||||
|
} catch (removeError) {
|
||||||
|
setError(removeError instanceof Error ? removeError.message : "删除 MCP Server 失败");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataListColumn<McpServer>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "MCP SERVER",
|
||||||
|
cell: (server) => (
|
||||||
|
<>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<ServerCog size={15} className="shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium text-foreground">{server.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||||
|
{server.url}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "tools",
|
||||||
|
header: "工具",
|
||||||
|
width: "md:w-[120px]",
|
||||||
|
cell: (server) => (
|
||||||
|
<span className="text-muted-foreground">{server.toolCount} 个</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "状态",
|
||||||
|
width: "md:w-[120px]",
|
||||||
|
cell: (server) => (
|
||||||
|
<Badge variant="outline" className="h-6 px-3">
|
||||||
|
{server.status === "active"
|
||||||
|
? "启用"
|
||||||
|
: server.status === "draft"
|
||||||
|
? "草稿"
|
||||||
|
: "归档"}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "操作",
|
||||||
|
width: "md:w-[260px]",
|
||||||
|
align: "right",
|
||||||
|
cellClassName: "flex justify-end gap-2",
|
||||||
|
cell: (server) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 border-hairline-strong"
|
||||||
|
disabled={syncingId === server.id}
|
||||||
|
onClick={() => void syncServer(server.id)}
|
||||||
|
>
|
||||||
|
{syncingId === server.id ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
)}
|
||||||
|
同步
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`编辑 ${server.name}`}
|
||||||
|
onClick={() => openEdit(server)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`删除 ${server.name}`}
|
||||||
|
disabled={deletingId === server.id}
|
||||||
|
onClick={() => void removeServer(server)}
|
||||||
|
>
|
||||||
|
{deletingId === server.id ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={14} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||||
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="caption-label text-muted-soft">MCP CONNECTIONS</div>
|
||||||
|
<h2 className="mt-1 text-lg font-medium text-foreground">MCP Server</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
连接远端 MCP Server,并将发现的工具同步到下方工具资源。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="gap-2" onClick={openCreate}>
|
||||||
|
<Plus size={15} />
|
||||||
|
添加 MCP Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataList<McpServer>
|
||||||
|
columns={columns}
|
||||||
|
rows={servers}
|
||||||
|
rowKey={(server) => server.id}
|
||||||
|
loading={loading}
|
||||||
|
loadingText="正在加载 MCP Server…"
|
||||||
|
error={error}
|
||||||
|
onRetry={() => void loadServers()}
|
||||||
|
empty={{
|
||||||
|
title: "暂无 MCP Server",
|
||||||
|
description: "添加连接后,系统会显式同步远端工具,不会自动暴露新能力。",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editing ? "编辑 MCP Server" : "添加 MCP Server"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
保存后会测试连接并同步工具。首版仅支持 Streamable HTTP。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">名称</span>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, name: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">Streamable HTTP URL</span>
|
||||||
|
<Input
|
||||||
|
value={form.url}
|
||||||
|
placeholder="https://mcp.example.com/mcp"
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, url: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">超时时间(秒)</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={120}
|
||||||
|
value={form.timeoutSeconds}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
timeoutSeconds: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">状态</span>
|
||||||
|
<Select
|
||||||
|
value={form.status}
|
||||||
|
onValueChange={(status: ToolStatus) =>
|
||||||
|
setForm((current) => ({ ...current, status }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">启用</SelectItem>
|
||||||
|
<SelectItem value="draft">草稿</SelectItem>
|
||||||
|
<SelectItem value="archived">归档</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">普通 Header(JSON)</span>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
value={form.headers}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({ ...current, headers: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">敏感 Header(JSON)</span>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
value={form.secretHeaders}
|
||||||
|
placeholder={'{"Authorization":"Bearer ..."}'}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
secretHeaders: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||||
|
密钥只保存在后端,重新打开时显示为打码占位符。
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">描述</span>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && <div className="text-sm text-destructive">{formError}</div>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void saveAndSync()} disabled={saving}>
|
||||||
|
{saving && <Loader2 size={15} className="animate-spin" />}
|
||||||
|
保存并同步工具
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import type { Edge } from "@xyflow/react";
|
import type { Edge } from "@xyflow/react";
|
||||||
import {
|
import {
|
||||||
Braces,
|
Braces,
|
||||||
|
CircleCheck,
|
||||||
|
CircleX,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -92,6 +94,24 @@ export function EdgeSettingsPanel({
|
|||||||
publish({ nextRules });
|
publish({ nextRules });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyActionResultPreset = (status: "ok" | "error") => {
|
||||||
|
const nextRules: ExpressionRule[] = [
|
||||||
|
{
|
||||||
|
variable: "system__last_action_status",
|
||||||
|
operator: "eq",
|
||||||
|
value: status,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMode("expression");
|
||||||
|
setCombinator("and");
|
||||||
|
setRules(nextRules);
|
||||||
|
publish({
|
||||||
|
nextMode: "expression",
|
||||||
|
nextCombinator: "and",
|
||||||
|
nextRules,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const parseValue = (value: string): unknown => {
|
const parseValue = (value: string): unknown => {
|
||||||
if (value === "true") return true;
|
if (value === "true") return true;
|
||||||
if (value === "false") return false;
|
if (value === "false") return false;
|
||||||
@@ -139,6 +159,35 @@ export function EdgeSettingsPanel({
|
|||||||
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{sourceType === "action" && (
|
||||||
|
<div className="rounded-xl border border-hairline bg-canvas-soft p-3">
|
||||||
|
<div className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Action 常用结果条件
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => applyActionResultPreset("ok")}
|
||||||
|
>
|
||||||
|
<CircleCheck size={14} />
|
||||||
|
执行成功
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => applyActionResultPreset("error")}
|
||||||
|
>
|
||||||
|
<CircleX size={14} />
|
||||||
|
执行失败
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode !== "always" && (
|
{mode !== "always" && (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||||
|
|||||||
@@ -347,19 +347,35 @@ export type HttpToolDefinition = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type McpToolDefinition = {
|
||||||
|
schemaVersion: number;
|
||||||
|
type: "mcp";
|
||||||
|
config: {
|
||||||
|
remoteToolName: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
schemaHash: string;
|
||||||
|
dynamicVariableAssignments: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type Tool = {
|
export type Tool = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
functionName: string;
|
functionName: string;
|
||||||
type: "end_call" | "http";
|
type: "end_call" | "http" | "mcp";
|
||||||
description: string;
|
description: string;
|
||||||
definition: EndCallToolDefinition | HttpToolDefinition;
|
definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition;
|
||||||
secrets: Record<string, unknown>;
|
secrets: Record<string, unknown>;
|
||||||
status: ToolStatus;
|
status: ToolStatus;
|
||||||
|
mcpServerId?: string | null;
|
||||||
|
remoteToolName?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolUpsert = Omit<Tool, "id" | "type" | "updatedAt">;
|
export type ToolUpsert = Omit<
|
||||||
|
Tool,
|
||||||
|
"id" | "type" | "remoteToolName" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
export const toolsApi = {
|
export const toolsApi = {
|
||||||
list: () => request<Tool[]>("/api/tools"),
|
list: () => request<Tool[]>("/api/tools"),
|
||||||
@@ -379,6 +395,55 @@ export const toolsApi = {
|
|||||||
request<Tool>(`/api/tools/${id}/duplicate`, { method: "POST" }),
|
request<Tool>(`/api/tools/${id}/duplicate`, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type McpServer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
transport: "streamable_http";
|
||||||
|
url: string;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
secretHeaders: Record<string, string>;
|
||||||
|
status: ToolStatus;
|
||||||
|
toolCount: number;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpServerUpsert = Omit<
|
||||||
|
McpServer,
|
||||||
|
"id" | "toolCount" | "lastSyncedAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type McpSyncResult = {
|
||||||
|
server: McpServer;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
tools: Tool[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mcpServersApi = {
|
||||||
|
list: () => request<McpServer[]>("/api/mcp-servers"),
|
||||||
|
create: (body: McpServerUpsert) =>
|
||||||
|
request<McpServer>("/api/mcp-servers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
update: (id: string, body: McpServerUpsert) =>
|
||||||
|
request<McpServer>(`/api/mcp-servers/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
sync: (id: string) =>
|
||||||
|
request<McpSyncResult>(`/api/mcp-servers/${id}/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
remove: (id: string) =>
|
||||||
|
request<{ ok: boolean }>(`/api/mcp-servers/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// ---------- 知识库 ----------
|
// ---------- 知识库 ----------
|
||||||
export type KnowledgeBase = {
|
export type KnowledgeBase = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user