One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
538 lines
19 KiB
Python
538 lines
19 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from typing import List
|
|
from app.core.logger import get_logger
|
|
from app.database import get_db
|
|
from app.models.user import User
|
|
from app.core.auth import get_current_user, require_permissions
|
|
from app.repositories.mcp_tool import MCPToolRepository
|
|
from app.repositories.agent import AgentRepository
|
|
|
|
# Enterprise feature check
|
|
try:
|
|
from app.enterprise.repositories.plan import PlanRepository
|
|
from app.enterprise.services.feature_access import require_accessible_subscription
|
|
HAS_ENTERPRISE = True
|
|
except ImportError:
|
|
HAS_ENTERPRISE = False
|
|
from app.models.schemas.mcp_tool import (
|
|
MCPToolCreate, MCPToolUpdate, MCPToolResponse,
|
|
MCPToolToAgentCreate, MCPToolToAgentResponse,
|
|
AgentMCPToolsResponse, MCPToolTestResponse, MCPToolReferencesResponse,
|
|
MCPTransportTypeEnum, unmask_secret_values
|
|
)
|
|
from app.core.outbound_policy import require_public_target
|
|
from app.core.params import DbIntId
|
|
from app.tools.mcp_manager import MCP_URL_LABEL, TEST_CONNECT_BUDGET_SECONDS, MCPToolsManager
|
|
from app.core.config import settings
|
|
from sqlalchemy.orm import Session
|
|
from uuid import UUID
|
|
|
|
def require_stdio_enabled() -> None:
|
|
"""Refuse a STDIO connector where running admin-typed commands is off."""
|
|
if not settings.MCP_STDIO_ENABLED:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Local (STDIO) MCP servers are not available here. Connect a remote server over HTTP or SSE.",
|
|
)
|
|
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def check_mcp_feature_access(current_user: User, db: Session):
|
|
"""Check if user has access to MCP tools feature"""
|
|
if not HAS_ENTERPRISE:
|
|
return # Allow access in non-enterprise mode
|
|
|
|
# Accessible = active/trial/past-due-in-period OR cancelled-but-still-in-
|
|
# paid-period; raises 403 when the org has no accessible plan.
|
|
subscription = require_accessible_subscription(db, current_user.organization_id)
|
|
plan_repo = PlanRepository(db)
|
|
|
|
# Check if MCP tools feature is available in the plan
|
|
if not plan_repo.check_feature_availability(str(subscription.plan_id), 'mcp_tools'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="MCP Tools feature is not available in your current plan. Please upgrade to access this feature."
|
|
)
|
|
|
|
|
|
@router.post("", response_model=MCPToolResponse)
|
|
async def create_mcp_tool(
|
|
mcp_tool_data: MCPToolCreate,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new MCP tool"""
|
|
try:
|
|
# Check MCP tools feature access
|
|
check_mcp_feature_access(current_user, db)
|
|
if mcp_tool_data.transport_type == MCPTransportTypeEnum.STDIO:
|
|
require_stdio_enabled()
|
|
else:
|
|
require_public_target(mcp_tool_data.url, MCP_URL_LABEL)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
|
|
# Set organization_id from current user
|
|
create_data = mcp_tool_data.model_dump()
|
|
create_data['organization_id'] = current_user.organization_id
|
|
|
|
# Check if MCP tool with same name already exists
|
|
existing = mcp_tool_repo.get_by_name(
|
|
mcp_tool_data.name,
|
|
current_user.organization_id
|
|
)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="MCP tool with this name already exists"
|
|
)
|
|
|
|
mcp_tool = mcp_tool_repo.create_mcp_tool(**create_data)
|
|
return mcp_tool
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"MCP tool creation error: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("", response_model=List[MCPToolResponse])
|
|
async def get_organization_mcp_tools(
|
|
enabled_only: bool = True,
|
|
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all MCP tools for the current user's organization"""
|
|
try:
|
|
# Check MCP tools feature access
|
|
check_mcp_feature_access(current_user, db)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tools = mcp_tool_repo.get_org_mcp_tools(
|
|
current_user.organization_id,
|
|
enabled_only=enabled_only
|
|
)
|
|
return mcp_tools
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching MCP tools: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/{mcp_tool_id}", response_model=MCPToolResponse)
|
|
async def get_mcp_tool(
|
|
mcp_tool_id: DbIntId,
|
|
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get a specific MCP tool"""
|
|
try:
|
|
check_mcp_feature_access(current_user, db)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
# Check if user has access to this MCP tool's organization
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this MCP tool"
|
|
)
|
|
|
|
return mcp_tool
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"Error fetching MCP tool: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/{mcp_tool_id}/test", response_model=MCPToolTestResponse)
|
|
async def test_mcp_tool(
|
|
mcp_tool_id: DbIntId,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Connect to a configured MCP tool once and report whether it comes up
|
|
and which functions it exposes — surfaces environment problems (e.g. a
|
|
missing npx) that otherwise only show as a silent 0-tool run."""
|
|
try:
|
|
check_mcp_feature_access(current_user, db)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this MCP tool"
|
|
)
|
|
|
|
# A request is waiting on this, so cap it no matter how generous the
|
|
# tool's own timeout is — a hung server must not hold the worker and
|
|
# its DB session for minutes.
|
|
manager = MCPToolsManager(connect_budget=TEST_CONNECT_BUDGET_SECONDS)
|
|
return await manager.test_tool_config(mcp_tool)
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"MCP tool test error: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/{mcp_tool_id}/references", response_model=MCPToolReferencesResponse)
|
|
async def get_mcp_tool_references(
|
|
mcp_tool_id: DbIntId,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""What currently points at this connector, so a delete confirmation can
|
|
name what it is about to break. Deliberately ungated, like the delete it
|
|
precedes."""
|
|
try:
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this MCP tool"
|
|
)
|
|
|
|
return MCPToolReferencesResponse(
|
|
agents=mcp_tool_repo.get_mcp_tool_agent_names(mcp_tool_id),
|
|
used_in_investigations=mcp_tool_repo.is_used_in_investigations(
|
|
mcp_tool_id, current_user.organization_id
|
|
),
|
|
)
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"Error fetching MCP tool references: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.put("/{mcp_tool_id}", response_model=MCPToolResponse)
|
|
async def update_mcp_tool(
|
|
mcp_tool_id: DbIntId,
|
|
update_data: MCPToolUpdate,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update an existing MCP tool"""
|
|
try:
|
|
check_mcp_feature_access(current_user, db)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
# Check if user has access to this MCP tool's organization
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to update this MCP tool"
|
|
)
|
|
|
|
# Update MCP tool with provided fields
|
|
update_dict = update_data.model_dump(exclude_unset=True)
|
|
|
|
# Secrets are masked on the way out, so a form that round-trips them
|
|
# untouched must leave the stored credential alone.
|
|
if 'env_vars' in update_dict:
|
|
update_dict['env_vars'] = unmask_secret_values(
|
|
update_dict['env_vars'], mcp_tool.env_vars
|
|
)
|
|
if 'headers' in update_dict:
|
|
update_dict['headers'] = unmask_secret_values(
|
|
update_dict['headers'], mcp_tool.headers
|
|
)
|
|
|
|
# transport_type is editable, and MCPToolUpdate carries none of the
|
|
# create-time validators, so check the state the update would leave
|
|
# behind — otherwise a connector can be saved with a transport it has
|
|
# no command or URL for and fails only mid-investigation.
|
|
transport = update_dict.get('transport_type', mcp_tool.transport_type)
|
|
transport = getattr(transport, 'value', transport)
|
|
if transport == MCPTransportTypeEnum.STDIO.value:
|
|
require_stdio_enabled()
|
|
if not update_dict.get('command', mcp_tool.command):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Command is required for STDIO transport"
|
|
)
|
|
elif not update_dict.get('url', mcp_tool.url):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="URL is required for SSE/HTTP transport"
|
|
)
|
|
else:
|
|
require_public_target(update_dict.get('url', mcp_tool.url), MCP_URL_LABEL)
|
|
|
|
# Check for name conflicts if name is being updated
|
|
if 'name' in update_dict:
|
|
existing = mcp_tool_repo.get_by_name(
|
|
update_dict['name'],
|
|
current_user.organization_id
|
|
)
|
|
if existing and existing.id != mcp_tool_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="MCP tool with this name already exists"
|
|
)
|
|
|
|
updated_mcp_tool = mcp_tool_repo.update_mcp_tool(mcp_tool_id, **update_dict)
|
|
return updated_mcp_tool
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"MCP tool update error: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/{mcp_tool_id}")
|
|
async def delete_mcp_tool(
|
|
mcp_tool_id: DbIntId,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Delete an MCP tool.
|
|
|
|
Deliberately not behind check_mcp_feature_access: an org whose plan no
|
|
longer carries mcp_tools must still be able to remove its connectors,
|
|
otherwise a downgrade would strand their credentials in env_vars for good.
|
|
"""
|
|
try:
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
# Check if user has access to this MCP tool's organization
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to delete this MCP tool"
|
|
)
|
|
|
|
success = mcp_tool_repo.delete_mcp_tool(mcp_tool_id)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Failed to delete MCP tool"
|
|
)
|
|
|
|
return {"message": "MCP tool deleted successfully"}
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"MCP tool deletion error: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# Agent-MCPTool association endpoints
|
|
@router.post("/agent-association", response_model=MCPToolToAgentResponse)
|
|
async def add_mcp_tool_to_agent(
|
|
association_data: MCPToolToAgentCreate,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add MCP tool to agent"""
|
|
try:
|
|
# Check MCP tools feature access
|
|
check_mcp_feature_access(current_user, db)
|
|
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
agent_repo = AgentRepository(db)
|
|
|
|
# Verify MCP tool exists and belongs to user's organization
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(association_data.mcp_tool_id)
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this MCP tool"
|
|
)
|
|
|
|
# Verify agent exists and belongs to user's organization
|
|
agent = agent_repo.get_agent(association_data.agent_id)
|
|
if not agent:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Agent not found"
|
|
)
|
|
|
|
if agent.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this agent"
|
|
)
|
|
|
|
# Create association
|
|
association = mcp_tool_repo.add_mcp_tool_to_agent(
|
|
association_data.mcp_tool_id,
|
|
association_data.agent_id
|
|
)
|
|
|
|
return association
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"Error adding MCP tool to agent: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/agent-association/{mcp_tool_id}/{agent_id}")
|
|
async def remove_mcp_tool_from_agent(
|
|
mcp_tool_id: DbIntId,
|
|
agent_id: UUID,
|
|
current_user: User = Depends(require_permissions("manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Remove MCP tool from agent"""
|
|
try:
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
agent_repo = AgentRepository(db)
|
|
|
|
# Verify MCP tool exists and belongs to user's organization
|
|
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
|
if not mcp_tool:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="MCP tool not found"
|
|
)
|
|
|
|
if mcp_tool.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this MCP tool"
|
|
)
|
|
|
|
# Verify agent exists and belongs to user's organization
|
|
agent = agent_repo.get_agent(agent_id)
|
|
if not agent:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Agent not found"
|
|
)
|
|
|
|
if agent.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this agent"
|
|
)
|
|
|
|
# Remove association
|
|
success = mcp_tool_repo.remove_mcp_tool_from_agent(mcp_tool_id, agent_id)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Association not found"
|
|
)
|
|
|
|
return {"message": "MCP tool removed from agent successfully"}
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"Error removing MCP tool from agent: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.get("/agent/{agent_id}", response_model=AgentMCPToolsResponse)
|
|
async def get_agent_mcp_tools(
|
|
agent_id: UUID,
|
|
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get all MCP tools associated with an agent"""
|
|
try:
|
|
mcp_tool_repo = MCPToolRepository(db)
|
|
agent_repo = AgentRepository(db)
|
|
|
|
# Verify agent exists and belongs to user's organization
|
|
agent = agent_repo.get_agent(agent_id)
|
|
if not agent:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Agent not found"
|
|
)
|
|
|
|
if agent.organization_id != current_user.organization_id:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Not authorized to access this agent"
|
|
)
|
|
|
|
# Get agent's MCP tools
|
|
mcp_tools = mcp_tool_repo.get_agent_mcp_tools(agent_id)
|
|
|
|
return AgentMCPToolsResponse(
|
|
id=agent.id,
|
|
name=agent.name,
|
|
mcp_tools=mcp_tools
|
|
)
|
|
|
|
except HTTPException as e:
|
|
raise e
|
|
except Exception as e:
|
|
logger.error(f"Error fetching agent MCP tools: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=str(e)) |