Files
kamidso-back/kamidso/engine/serve/connector/api/endpoints.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:47 +03:30

330 lines
12 KiB
Python

"""REST API endpoints for connector management."""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from kamidso.engine.core.component import SystemApp
from kamidso.engine.serve.core import Result, blocking_func_to_async
from ..config import SERVE_SERVICE_COMPONENT_NAME, ServeConfig
from ..service.service import (
ConnectorCreateRequest,
ConnectorResponse,
ConnectorService,
ConnectorToolsResponse,
ConnectorUpdateRequest,
)
from .schemas import ConnectorTypeOption
logger = logging.getLogger(__name__)
router = APIRouter()
global_system_app: Optional[SystemApp] = None
def get_service() -> ConnectorService:
return global_system_app.get_component(
SERVE_SERVICE_COMPONENT_NAME, ConnectorService
)
# Synthetic catalog entry for user-defined custom MCP servers.
# Kept in sync with CustomMcpForm (Task F) on the frontend.
# Note: auth_fields here include extra keys (`options`, `default`) beyond the
# `AuthField` schema in catalog.py — the frontend CustomMcpForm tolerates both shapes.
_CUSTOM_MCP_OPTION = ConnectorTypeOption(
type="custom_mcp",
display_name="自定义 MCP Server",
description="接入任意 SSE / Streamable HTTP MCP Server",
category="custom",
is_custom=True,
auth_fields=[
{
"name": "server_uri",
"label": "Endpoint URL",
"type": "url",
"required": True,
},
{
# Display label intentionally generic — the form switches the
# server_uri label/placeholder based on this value.
"name": "transport",
"label": "传输协议",
"type": "select",
"options": ["sse", "streamable_http"],
"required": True,
"default": "streamable_http",
},
{
# Optional free-text description shown in the agent's connector
# prompt block. Manager.list_active prefers extra_config.description
# over catalog.description, so this is the canonical way for users
# to label what a custom_mcp connector exposes.
"name": "description",
"label": "连接器描述",
"type": "text",
"required": False,
},
{
"name": "auth_type",
"label": "认证方式",
"type": "select",
"options": ["none", "bearer", "token"],
"required": True,
},
{
"name": "token",
"label": "Token",
"type": "password",
"required": False,
},
{
"name": "header_name",
"label": "Token Header 名",
"type": "text",
"required": False,
"default": "Authorization",
},
],
)
@router.get("/types", response_model=Result[List[ConnectorTypeOption]])
async def list_connector_types() -> Result[List[Dict[str, Any]]]:
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager as _CM
if global_system_app is None:
raise HTTPException(status_code=503, detail="SystemApp not initialised")
manager = global_system_app.get_component(
"connector_manager", _CM, default_component=None
)
if manager is None:
raise HTTPException(status_code=503, detail="ConnectorManager not available")
options: List[ConnectorTypeOption] = []
# Built-in templates share the unified auth_fields with custom_mcp so the
# frontend ConnectorForm renders the same UX everywhere (transport /
# auth_type / token / header_name / description). Catalog entries now act
# purely as display-only metadata (type / display_name / icon / category)
# plus the optional default transport baked into mcp_server.transport.
for entry in manager.get_catalog().list():
options.append(
ConnectorTypeOption(
type=entry.type,
display_name=entry.display_name,
description=entry.description,
icon=entry.icon,
category=entry.category,
is_custom=False,
auth_fields=_CUSTOM_MCP_OPTION.auth_fields,
)
)
options.append(_CUSTOM_MCP_OPTION)
from kamidso.native_connectors.flags import (
didar_connector_enabled,
native_connectors_v1_enabled,
)
from kamidso.native_connectors.registry import get_registry
if native_connectors_v1_enabled() and didar_connector_enabled():
registry = get_registry()
if registry.contains("didar"):
provider = registry.get("didar")
options.append(
ConnectorTypeOption(
type=provider.id,
display_name=provider.display_name,
description="Read-only Didar CRM connector",
category=provider.category or "crm",
is_custom=False,
auth_fields=[
{
"name": provider.credential_field,
"label": "API Key",
"type": "password",
"required": True,
}
],
)
)
return Result.succ([o.model_dump() for o in options])
@router.post("/", response_model=Result[ConnectorResponse])
async def create_connector(
request: ConnectorCreateRequest,
service: ConnectorService = Depends(get_service),
) -> Result[ConnectorResponse]:
try:
return Result.succ(
await blocking_func_to_async(
global_system_app, service.create_connector, request
)
)
except Exception as e:
logger.exception("Create connector failed")
return Result.failed(msg=str(e))
@router.get("/", response_model=Result[List[ConnectorResponse]])
async def list_connectors(
user_name: Optional[str] = Query(default=None, description="Filter by user name"),
sys_code: Optional[str] = Query(default=None, description="Filter by system code"),
service: ConnectorService = Depends(get_service),
) -> Result[List[ConnectorResponse]]:
try:
return Result.succ(
service.list_connectors(user_name=user_name, sys_code=sys_code)
)
except Exception as e:
logger.exception("List connectors failed")
return Result.failed(msg=str(e))
class ConfirmRequest(BaseModel):
confirm_id: str
approved: bool
# IMPORTANT: keep these fixed-path routes BEFORE any "/{connector_id}" routes,
# otherwise FastAPI matches /{connector_id} first and treats "pending-confirms" /
# "confirm" as a connector_id value.
@router.get("/pending-confirms")
async def list_pending_confirms() -> List[dict]:
from kamidso.engine.core.agent.resource.connector.confirmation import _PENDING_CONFIRMATIONS
return list(_PENDING_CONFIRMATIONS.values())
@router.post("/confirm")
async def confirm_action(request: ConfirmRequest) -> Dict[str, str]:
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager as _CM
if global_system_app is None:
raise HTTPException(status_code=503, detail="SystemApp not initialised")
cm = global_system_app.get_component(
"connector_manager", _CM, default_component=None
)
if cm is None:
raise HTTPException(status_code=503, detail="ConnectorManager not available")
registry = cm.get_confirmation_registry()
resolved = registry.resolve(request.confirm_id, request.approved)
if not resolved:
raise HTTPException(status_code=404, detail="confirm_id not found or expired")
from kamidso.engine.core.agent.resource.connector.confirmation import _PENDING_CONFIRMATIONS
_PENDING_CONFIRMATIONS.pop(request.confirm_id, None)
return {"status": "ok"}
@router.get("/{connector_id}", response_model=Result[ConnectorResponse])
async def get_connector(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[ConnectorResponse]:
connector = service.get_connector(connector_id)
if connector is None:
raise HTTPException(
status_code=404, detail=f"Connector '{connector_id}' not found"
)
return Result.succ(connector)
@router.put("/{connector_id}", response_model=Result[ConnectorResponse])
async def update_connector(
connector_id: str,
request: ConnectorUpdateRequest,
service: ConnectorService = Depends(get_service),
) -> Result[ConnectorResponse]:
# service.update_connector is a sync method that internally drives the
# ConnectorManager via loop.run_until_complete(...). Calling it directly
# from this async handler runs that on the live FastAPI event loop and
# raises "This event loop is already running" — which the service's
# broad except then writes back as status='error'. Mirror the POST route
# and dispatch through blocking_func_to_async so the sync work executes
# on a worker thread with its own fresh loop.
try:
return Result.succ(
await blocking_func_to_async(
global_system_app,
service.update_connector,
connector_id,
request,
)
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.exception("Update connector failed")
return Result.failed(msg=str(e))
@router.delete("/{connector_id}", response_model=Result[None])
async def delete_connector(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[None]:
try:
service.delete_connector(connector_id)
return Result.succ(None)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.exception("Delete connector failed")
return Result.failed(msg=str(e))
@router.post("/{connector_id}/test", response_model=Result[Dict[str, Any]])
async def test_connector(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[Dict[str, Any]]:
try:
return Result.succ(await service.test_connection(connector_id))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.exception("Test connector failed")
return Result.failed(msg=str(e))
@router.get("/{connector_id}/functions")
async def get_connector_functions(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[Dict[str, Any]]:
try:
return Result.succ(service.list_native_functions(connector_id))
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.exception("Get connector functions failed")
return Result.failed(msg=str(exc))
@router.get("/{connector_id}/tools", response_model=Result[ConnectorToolsResponse])
async def get_connector_tools(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[ConnectorToolsResponse]:
try:
return Result.succ(service.list_tools(connector_id))
except HTTPException:
# Let FastAPI surface 404 directly (matches get_connector behaviour).
raise
except Exception as e:
logger.exception("Get connector tools failed")
return Result.failed(msg=str(e))
def init_endpoints(system_app: SystemApp, config: ServeConfig) -> None:
global global_system_app
system_app.register(ConnectorService, config=config)
global_system_app = system_app