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>
900 lines
35 KiB
Python
900 lines
35 KiB
Python
"""Connector service for managing external connector instances."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
from fastapi import HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
|
|
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager, ConnectorStatus
|
|
from kamidso.engine.core.agent.resource.tool.base import BaseTool
|
|
from kamidso.engine.core.component import SystemApp
|
|
from kamidso.engine.core.storage.metadata import BaseDao
|
|
from kamidso.engine.core.util import get_or_create_event_loop
|
|
from kamidso.engine.serve.core import BaseService
|
|
from kamidso.engine.serve.core.config import BaseServeConfig
|
|
|
|
from ..config import SERVE_SERVICE_COMPONENT_NAME, ServeConfig
|
|
from ..models.models import ConnectorInstanceDao, ConnectorInstanceEntity
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _utc_stamp() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
class ConnectorProbeFailed(ValueError):
|
|
"""Health check failed before a native connector credential was saved."""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
status_code: Optional[int] = None,
|
|
error_type: Optional[str] = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.error_type = error_type
|
|
|
|
|
|
class ConnectorCreateRequest(BaseModel):
|
|
"""Request model for creating a connector."""
|
|
|
|
connector_type: str = Field(..., description="Connector type, e.g. yuque, feishu")
|
|
display_name: str = Field(..., description="Display name for the connector")
|
|
credentials: Dict[str, Any] = Field(..., description="Connector credentials")
|
|
config: Optional[Dict[str, Any]] = Field(
|
|
default=None, description="Optional extra config"
|
|
)
|
|
user_name: Optional[str] = Field(default=None, description="User name")
|
|
sys_code: Optional[str] = Field(default=None, description="System code")
|
|
|
|
|
|
class ConnectorUpdateRequest(BaseModel):
|
|
"""Request model for updating a connector."""
|
|
|
|
display_name: Optional[str] = Field(
|
|
default=None, description="Display name for the connector"
|
|
)
|
|
credentials: Optional[Dict[str, Any]] = Field(
|
|
default=None, description="Connector credentials"
|
|
)
|
|
config: Optional[Dict[str, Any]] = Field(
|
|
default=None, description="Optional extra config"
|
|
)
|
|
|
|
|
|
class ConnectorResponse(BaseModel):
|
|
"""Response model for a connector."""
|
|
|
|
connector_id: str = Field(..., description="Connector UUID")
|
|
connector_type: str = Field(..., description="Connector type")
|
|
display_name: str = Field(..., description="Display name")
|
|
status: str = Field(
|
|
...,
|
|
description="Status: active / error / disconnected / needs_reactivation",
|
|
)
|
|
is_custom: bool = Field(
|
|
default=False,
|
|
description="True iff connector_type is a custom (user-defined) MCP type",
|
|
)
|
|
config: Optional[Dict[str, Any]] = Field(
|
|
default=None, description="Optional extra config"
|
|
)
|
|
user_name: Optional[str] = Field(default=None, description="User name")
|
|
sys_code: Optional[str] = Field(default=None, description="System code")
|
|
gmt_created: Optional[str] = Field(default=None, description="Creation time")
|
|
gmt_modified: Optional[str] = Field(default=None, description="Last modified time")
|
|
|
|
|
|
class ConnectorToolsResponse(BaseModel):
|
|
"""Response model for the per-connector tool listing endpoint.
|
|
|
|
``state`` indicates the runtime status of the underlying tool pack:
|
|
|
|
* ``"active"`` — pack is loaded in the runtime ConnectorManager.
|
|
* ``"inactive"`` — pack is not currently loaded (failed activation /
|
|
removed). ``tools`` is an empty list in this case.
|
|
* ``"not_mcp"`` — reserved for future non-MCP connector types; unreachable
|
|
in v1 since every connector is MCP-based today.
|
|
"""
|
|
|
|
connector_id: str = Field(..., description="Connector UUID")
|
|
state: Literal["active", "inactive", "not_mcp"] = Field(
|
|
...,
|
|
description="Runtime tool-pack state for the connector",
|
|
)
|
|
# Structurally a List[ConnectorToolSummary]; declared as List[Dict[str, Any]]
|
|
# because Pydantic v2 cannot directly serialise nested TypedDicts.
|
|
tools: List[Dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description="Tool summaries; each entry shaped like ConnectorToolSummary",
|
|
)
|
|
|
|
|
|
def _entity_to_response(entity: ConnectorInstanceEntity) -> ConnectorResponse:
|
|
"""Convert a ConnectorInstanceEntity to a ConnectorResponse."""
|
|
config = None
|
|
if entity.config_json:
|
|
try:
|
|
config = json.loads(entity.config_json)
|
|
except Exception:
|
|
config = None
|
|
|
|
gmt_created = None
|
|
if entity.gmt_created:
|
|
gmt_created = entity.gmt_created.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
gmt_modified = None
|
|
if entity.gmt_modified:
|
|
gmt_modified = entity.gmt_modified.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
return ConnectorResponse(
|
|
connector_id=entity.connector_id,
|
|
connector_type=entity.connector_type,
|
|
display_name=entity.display_name or "",
|
|
status=entity.status or "active",
|
|
is_custom=ConnectorManager.is_custom_connector_type(entity.connector_type),
|
|
config=config,
|
|
user_name=entity.user_name,
|
|
sys_code=entity.sys_code,
|
|
gmt_created=gmt_created,
|
|
gmt_modified=gmt_modified,
|
|
)
|
|
|
|
|
|
# Per-tool args JSON cap. When exceeded, the tool's args block is replaced with
|
|
# a ``_truncated`` marker to keep response payloads bounded for very large
|
|
# JSON Schemas while preserving the top-level shape consumers depend on.
|
|
_TOOL_ARGS_BYTE_CAP = 8192
|
|
|
|
|
|
class ConnectorService(
|
|
BaseService[ConnectorInstanceEntity, ConnectorCreateRequest, ConnectorUpdateRequest]
|
|
):
|
|
"""Service for managing connector instances."""
|
|
|
|
name = SERVE_SERVICE_COMPONENT_NAME
|
|
|
|
def __init__(
|
|
self,
|
|
system_app: SystemApp,
|
|
config: Optional[ServeConfig] = None,
|
|
dao: Optional[ConnectorInstanceDao] = None,
|
|
):
|
|
self._serve_config = config or ServeConfig()
|
|
self._dao = dao or ConnectorInstanceDao(self._serve_config)
|
|
self._credential_store = CredentialStore(system_app=system_app)
|
|
super().__init__(system_app)
|
|
|
|
def init_app(self, system_app: SystemApp) -> None:
|
|
"""Initialize the service."""
|
|
self._system_app = system_app
|
|
self._credential_store = CredentialStore(system_app=system_app)
|
|
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
|
from kamidso.native_connectors.registry import get_registry
|
|
|
|
if native_connectors_v1_enabled():
|
|
get_registry()
|
|
|
|
def _is_native_type(self, connector_type: Optional[str]) -> bool:
|
|
from kamidso.native_connectors.flags import native_connectors_v1_enabled
|
|
from kamidso.native_connectors.registry import get_registry
|
|
|
|
if not native_connectors_v1_enabled() or not connector_type:
|
|
return False
|
|
return get_registry().contains(connector_type)
|
|
|
|
def _require_durable_native_key(self) -> None:
|
|
from kamidso.native_connectors.flags import is_production
|
|
|
|
if is_production() and not self._credential_store.has_durable_key():
|
|
raise ValueError(
|
|
"ENCRYPT_KEY must be configured before creating a native connector "
|
|
"in production."
|
|
)
|
|
|
|
def _native_executor(self):
|
|
from kamidso.native_connectors.executor import NativeConnectorExecutor
|
|
|
|
return NativeConnectorExecutor(credential_store=self._credential_store)
|
|
|
|
def _create_native_connector(self, request: ConnectorCreateRequest) -> ConnectorResponse:
|
|
"""Persist a native connector only after its catalog health check succeeds."""
|
|
self._require_durable_native_key()
|
|
from kamidso.native_connectors.registry import get_registry
|
|
|
|
provider = get_registry().get(request.connector_type)
|
|
credentials = {k: str(v) for k, v in request.credentials.items()}
|
|
if not str(credentials.get(provider.credential_field) or "").strip():
|
|
raise ValueError(
|
|
f"Missing credential field: {provider.credential_field}"
|
|
)
|
|
loop = get_or_create_event_loop()
|
|
probed = loop.run_until_complete(
|
|
self._native_executor().probe(request.connector_type, credentials)
|
|
)
|
|
if not probed.get("success"):
|
|
status_code = probed.get("status_code")
|
|
raise ConnectorProbeFailed(
|
|
str(probed.get("message") or "Connection test failed"),
|
|
status_code=status_code if isinstance(status_code, int) else None,
|
|
error_type=str(probed.get("error_type") or "") or None,
|
|
)
|
|
|
|
connector_id = str(uuid.uuid4())
|
|
encryption_salt = self._credential_store.generate_salt()
|
|
encrypted_credentials = self._credential_store.encrypt(
|
|
credentials,
|
|
encryption_salt,
|
|
)
|
|
config = dict(request.config or {})
|
|
config["last_tested_at"] = _utc_stamp()
|
|
config["last_test_success"] = True
|
|
config_json = json.dumps(config)
|
|
entity = ConnectorInstanceEntity(
|
|
connector_id=connector_id,
|
|
connector_type=request.connector_type,
|
|
display_name=request.display_name,
|
|
encrypted_credentials=encrypted_credentials,
|
|
encryption_salt=encryption_salt,
|
|
status="active",
|
|
config_json=config_json,
|
|
user_name=request.user_name,
|
|
sys_code=request.sys_code,
|
|
)
|
|
with self._dao.session() as session:
|
|
session.add(entity)
|
|
session.flush()
|
|
session.refresh(entity)
|
|
return _entity_to_response(entity)
|
|
|
|
def list_native_functions(self, connector_id: str) -> Dict[str, Any]:
|
|
"""Return the predefined function catalog summary for one native connector."""
|
|
connector = self.get_connector(connector_id)
|
|
if connector is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Connector '{connector_id}' not found"
|
|
)
|
|
if not self._is_native_type(connector.connector_type):
|
|
raise ValueError(f"Connector '{connector_id}' is not a native connector")
|
|
from kamidso.native_connectors.registry import get_registry
|
|
|
|
provider = get_registry().get(connector.connector_type)
|
|
return {
|
|
"provider": provider.id,
|
|
"functions": [
|
|
{
|
|
"name": function.name,
|
|
"description": function.description,
|
|
"domain": function.domain,
|
|
"effect": function.effect,
|
|
"enabled": function.enabled,
|
|
}
|
|
for function in provider.functions
|
|
],
|
|
}
|
|
|
|
def after_start(self):
|
|
"""Rehydrate active connectors into the runtime connector manager."""
|
|
super_after_start = getattr(super(), "after_start", None)
|
|
if callable(super_after_start):
|
|
super_after_start()
|
|
|
|
connector_manager = self.system_app.get_component(
|
|
"connector_manager",
|
|
ConnectorManager,
|
|
default_component=None,
|
|
)
|
|
if connector_manager is None:
|
|
return
|
|
|
|
loop = get_or_create_event_loop()
|
|
with self._dao.session() as session:
|
|
active_connectors = []
|
|
for entity in (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.status == "active")
|
|
.all()
|
|
):
|
|
extra_config: Optional[Dict[str, Any]] = None
|
|
if entity.config_json:
|
|
try:
|
|
extra_config = json.loads(entity.config_json)
|
|
except Exception:
|
|
extra_config = None
|
|
active_connectors.append(
|
|
{
|
|
"connector_id": entity.connector_id,
|
|
"connector_type": entity.connector_type,
|
|
"display_name": entity.display_name,
|
|
"encrypted_credentials": entity.encrypted_credentials,
|
|
"encryption_salt": entity.encryption_salt,
|
|
"config": extra_config,
|
|
}
|
|
)
|
|
|
|
for connector in active_connectors:
|
|
if self._is_native_type(connector.get("connector_type")):
|
|
continue
|
|
try:
|
|
credentials = self._credential_store.decrypt(
|
|
connector["encrypted_credentials"],
|
|
connector["encryption_salt"],
|
|
)
|
|
loop.run_until_complete(
|
|
connector_manager.create_connector(
|
|
connector_type=connector["connector_type"],
|
|
credentials=credentials,
|
|
name=connector["display_name"],
|
|
extra_config=connector.get("config"),
|
|
connector_id=connector["connector_id"],
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
# Catalog downgrade compatibility: pre-1.5 instances may lack
|
|
# server_uri in config_json. Mark them as needs_reactivation so
|
|
# UI can prompt user.
|
|
msg = str(exc)
|
|
if "requires extra_config.server_uri" in msg:
|
|
try:
|
|
with self._dao.session() as session:
|
|
row = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(
|
|
ConnectorInstanceEntity.connector_id
|
|
== connector["connector_id"]
|
|
)
|
|
.first()
|
|
)
|
|
if row is not None:
|
|
row.status = "needs_reactivation"
|
|
session.flush()
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to mark connector '%s' as needs_reactivation",
|
|
connector["connector_id"],
|
|
)
|
|
logger.warning(
|
|
"Connector '%s' (type=%s) needs reactivation: missing "
|
|
"server_uri in config_json (likely created before "
|
|
"catalog downgrade)",
|
|
connector["connector_id"],
|
|
connector["connector_type"],
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Failed to rehydrate connector '%s': %s",
|
|
connector["connector_id"],
|
|
exc,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to rehydrate connector '%s': %s",
|
|
connector["connector_id"],
|
|
exc,
|
|
)
|
|
|
|
@property
|
|
def dao(
|
|
self,
|
|
) -> BaseDao[
|
|
ConnectorInstanceEntity, ConnectorCreateRequest, ConnectorUpdateRequest
|
|
]:
|
|
"""Returns the internal DAO."""
|
|
return self._dao
|
|
|
|
@property
|
|
def config(self) -> BaseServeConfig:
|
|
"""Returns the internal ServeConfig."""
|
|
return self._serve_config
|
|
|
|
def create_connector(self, request: ConnectorCreateRequest) -> ConnectorResponse:
|
|
"""Create a new connector instance.
|
|
|
|
Args:
|
|
request (ConnectorCreateRequest): The create request.
|
|
|
|
Returns:
|
|
ConnectorResponse: The created connector response.
|
|
"""
|
|
if self._is_native_type(request.connector_type):
|
|
return self._create_native_connector(request)
|
|
|
|
connector_id = str(uuid.uuid4())
|
|
encryption_salt = self._credential_store.generate_salt()
|
|
encrypted_credentials = self._credential_store.encrypt(
|
|
{k: str(v) for k, v in request.credentials.items()},
|
|
encryption_salt,
|
|
)
|
|
config_json = json.dumps(request.config) if request.config else None
|
|
|
|
entity = ConnectorInstanceEntity(
|
|
connector_id=connector_id,
|
|
connector_type=request.connector_type,
|
|
display_name=request.display_name,
|
|
encrypted_credentials=encrypted_credentials,
|
|
encryption_salt=encryption_salt,
|
|
status="active",
|
|
config_json=config_json,
|
|
user_name=request.user_name,
|
|
sys_code=request.sys_code,
|
|
)
|
|
|
|
with self._dao.session() as session:
|
|
session.add(entity)
|
|
session.flush()
|
|
session.refresh(entity)
|
|
|
|
connector_manager = self.system_app.get_component(
|
|
"connector_manager",
|
|
ConnectorManager,
|
|
default_component=None,
|
|
)
|
|
if connector_manager is not None:
|
|
loop = get_or_create_event_loop()
|
|
loop.run_until_complete(
|
|
connector_manager.create_connector(
|
|
connector_type=request.connector_type,
|
|
credentials=request.credentials,
|
|
name=request.display_name,
|
|
extra_config=request.config,
|
|
connector_id=connector_id,
|
|
)
|
|
)
|
|
|
|
return _entity_to_response(entity)
|
|
|
|
def list_connectors(
|
|
self,
|
|
user_name: Optional[str] = None,
|
|
sys_code: Optional[str] = None,
|
|
) -> List[ConnectorResponse]:
|
|
"""List all connector instances, optionally filtered by user_name/sys_code.
|
|
|
|
Args:
|
|
user_name (Optional[str]): Filter by user name.
|
|
sys_code (Optional[str]): Filter by system code.
|
|
|
|
Returns:
|
|
List[ConnectorResponse]: List of connector responses.
|
|
"""
|
|
with self._dao.session() as session:
|
|
query = session.query(ConnectorInstanceEntity)
|
|
if user_name is not None:
|
|
query = query.filter(ConnectorInstanceEntity.user_name == user_name)
|
|
if sys_code is not None:
|
|
query = query.filter(ConnectorInstanceEntity.sys_code == sys_code)
|
|
entities = query.all()
|
|
return [_entity_to_response(e) for e in entities]
|
|
|
|
def get_connector(self, connector_id: str) -> Optional[ConnectorResponse]:
|
|
"""Get a connector instance by ID.
|
|
|
|
Args:
|
|
connector_id (str): The connector UUID.
|
|
|
|
Returns:
|
|
Optional[ConnectorResponse]: The connector response or None if not found.
|
|
"""
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None:
|
|
return None
|
|
return _entity_to_response(entity)
|
|
|
|
def update_connector(
|
|
self, connector_id: str, request: ConnectorUpdateRequest
|
|
) -> ConnectorResponse:
|
|
"""Update a connector instance.
|
|
|
|
Args:
|
|
connector_id (str): The connector UUID.
|
|
request (ConnectorUpdateRequest): The update request.
|
|
|
|
Returns:
|
|
ConnectorResponse: The updated connector response.
|
|
|
|
Raises:
|
|
ValueError: If the connector is not found.
|
|
"""
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None:
|
|
raise ValueError(f"Connector '{connector_id}' not found")
|
|
|
|
if request.display_name is not None:
|
|
entity.display_name = request.display_name
|
|
|
|
credentials_changed = False
|
|
# P0-3 fix: merge credentials instead of overwriting.
|
|
# Empty dict / None means "no credential changes", do nothing.
|
|
if request.credentials is not None and len(request.credentials) > 0:
|
|
try:
|
|
existing = (
|
|
self._credential_store.decrypt(
|
|
entity.encrypted_credentials,
|
|
entity.encryption_salt,
|
|
)
|
|
or {}
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"Failed to decrypt existing credentials for '%s'; "
|
|
"proceeding with new credentials only",
|
|
connector_id,
|
|
)
|
|
existing = {}
|
|
merged = {
|
|
**existing,
|
|
**{k: str(v) for k, v in request.credentials.items()},
|
|
}
|
|
if self._is_native_type(entity.connector_type):
|
|
self._require_durable_native_key()
|
|
loop = get_or_create_event_loop()
|
|
probed = loop.run_until_complete(
|
|
self._native_executor().probe(entity.connector_type, merged)
|
|
)
|
|
if not probed.get("success"):
|
|
status_code = probed.get("status_code")
|
|
raise ConnectorProbeFailed(
|
|
str(probed.get("message") or "Connection test failed"),
|
|
status_code=status_code if isinstance(status_code, int) else None,
|
|
error_type=str(probed.get("error_type") or "") or None,
|
|
)
|
|
entity.encryption_salt = self._credential_store.generate_salt()
|
|
entity.encrypted_credentials = self._credential_store.encrypt(
|
|
merged,
|
|
entity.encryption_salt,
|
|
)
|
|
credentials_changed = True
|
|
|
|
config_changed = False
|
|
if request.config is not None:
|
|
entity.config_json = json.dumps(request.config)
|
|
config_changed = True
|
|
|
|
if self._is_native_type(entity.connector_type):
|
|
if credentials_changed or config_changed:
|
|
entity.status = "active"
|
|
elif credentials_changed or config_changed:
|
|
previous_status = entity.status
|
|
entity.status = "active"
|
|
|
|
connector_manager = self.system_app.get_component(
|
|
"connector_manager",
|
|
ConnectorManager,
|
|
default_component=None,
|
|
)
|
|
if connector_manager is not None:
|
|
try:
|
|
decrypted_creds = (
|
|
self._credential_store.decrypt(
|
|
entity.encrypted_credentials,
|
|
entity.encryption_salt,
|
|
)
|
|
or {}
|
|
)
|
|
except Exception:
|
|
decrypted_creds = {}
|
|
|
|
extra_config: Optional[Dict[str, Any]] = None
|
|
if entity.config_json:
|
|
try:
|
|
extra_config = json.loads(entity.config_json)
|
|
except Exception:
|
|
extra_config = None
|
|
|
|
loop = get_or_create_event_loop()
|
|
try:
|
|
loop.run_until_complete(
|
|
connector_manager.remove_connector(connector_id)
|
|
)
|
|
except Exception:
|
|
pass # remove is no-op if connector_id not active
|
|
try:
|
|
loop.run_until_complete(
|
|
connector_manager.create_connector(
|
|
connector_type=entity.connector_type,
|
|
credentials=decrypted_creds,
|
|
name=entity.display_name,
|
|
extra_config=extra_config,
|
|
connector_id=connector_id,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
entity.status = "error"
|
|
logger.warning(
|
|
"Re-activation failed for connector '%s' "
|
|
"(was %s, now error): %s",
|
|
connector_id,
|
|
previous_status,
|
|
exc,
|
|
)
|
|
|
|
session.flush()
|
|
session.refresh(entity)
|
|
return _entity_to_response(entity)
|
|
|
|
def delete_connector(self, connector_id: str) -> None:
|
|
"""Delete a connector instance.
|
|
|
|
Args:
|
|
connector_id (str): The connector UUID.
|
|
|
|
Raises:
|
|
ValueError: If the connector is not found.
|
|
"""
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None:
|
|
raise ValueError(f"Connector '{connector_id}' not found")
|
|
session.delete(entity)
|
|
|
|
def retire_connector(self, connector_id: str) -> None:
|
|
"""Hide a connector from new chats without dropping its history row."""
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None:
|
|
raise ValueError(f"Connector '{connector_id}' not found")
|
|
entity.status = "deleted"
|
|
connector_manager = self.system_app.get_component(
|
|
"connector_manager",
|
|
ConnectorManager,
|
|
default_component=None,
|
|
)
|
|
if connector_manager is None:
|
|
return
|
|
loop = get_or_create_event_loop()
|
|
try:
|
|
loop.run_until_complete(connector_manager.remove_connector(connector_id))
|
|
except Exception:
|
|
logger.info("Connector '%s' was not active in the MCP manager", connector_id)
|
|
|
|
def record_last_test(self, connector_id: str, *, success: bool) -> None:
|
|
"""Remember the latest health check without storing credentials."""
|
|
try:
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None:
|
|
return
|
|
config: Dict[str, Any] = {}
|
|
if entity.config_json:
|
|
try:
|
|
loaded = json.loads(entity.config_json)
|
|
if isinstance(loaded, dict):
|
|
config = loaded
|
|
except Exception:
|
|
config = {}
|
|
config["last_tested_at"] = _utc_stamp()
|
|
config["last_test_success"] = success
|
|
entity.config_json = json.dumps(config)
|
|
except Exception:
|
|
logger.exception("Failed to record the last test for '%s'", connector_id)
|
|
|
|
async def test_native_credentials(
|
|
self, connector_type: str, credentials: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Probe a native provider without writing a connector row."""
|
|
if not self._is_native_type(connector_type):
|
|
raise ValueError("Unknown native provider")
|
|
from kamidso.native_connectors.registry import get_registry
|
|
|
|
provider = get_registry().get(connector_type)
|
|
cleaned = {
|
|
str(key): str(value)
|
|
for key, value in credentials.items()
|
|
if str(value).strip()
|
|
}
|
|
if not str(cleaned.get(provider.credential_field) or "").strip():
|
|
raise ValueError(f"Missing credential field: {provider.credential_field}")
|
|
return await self._native_executor().probe(connector_type, cleaned)
|
|
|
|
def list_tools(self, connector_id: str) -> ConnectorToolsResponse:
|
|
"""Return the tool summaries for a single connector.
|
|
|
|
Args:
|
|
connector_id (str): The connector UUID.
|
|
|
|
Returns:
|
|
ConnectorToolsResponse: Wrapping ``state`` and ``tools``. When the
|
|
pack is not present in the runtime manager (e.g. activation failed
|
|
after restart), ``state`` is ``"inactive"`` and ``tools`` is empty.
|
|
|
|
Raises:
|
|
HTTPException: 404 when *connector_id* is not present in the DB.
|
|
"""
|
|
if self.get_connector(connector_id) is None:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"Connector '{connector_id}' not found"
|
|
)
|
|
current = self.get_connector(connector_id)
|
|
if current is not None and self._is_native_type(current.connector_type):
|
|
return ConnectorToolsResponse(
|
|
connector_id=connector_id,
|
|
state="not_mcp",
|
|
tools=[],
|
|
)
|
|
|
|
connector_manager = self.system_app.get_component(
|
|
"connector_manager",
|
|
ConnectorManager,
|
|
default_component=None,
|
|
)
|
|
|
|
# Forward-compat: in v1 every connector is MCP-based, so we never
|
|
# produce "not_mcp" — the branch is here for future non-MCP types.
|
|
state: Literal["active", "inactive", "not_mcp"]
|
|
tools: List[Dict[str, Any]] = []
|
|
|
|
summary: Optional[List[Dict[str, Any]]] = None
|
|
if connector_manager is not None:
|
|
summary = connector_manager._tool_summary_for(connector_id)
|
|
|
|
if summary is None:
|
|
state = "inactive"
|
|
else:
|
|
# Apply per-tool args cap. Marker stays inside ``args`` to keep the
|
|
# top-level tool entry shape stable for the caller.
|
|
for tool in summary:
|
|
args = tool.get("args") or {}
|
|
args_json = json.dumps(args)
|
|
byte_count = len(args_json.encode("utf-8"))
|
|
if byte_count > _TOOL_ARGS_BYTE_CAP:
|
|
tool["args"] = {
|
|
"_truncated": True,
|
|
"byte_count": byte_count,
|
|
}
|
|
tools = summary
|
|
|
|
# Cross-check DB status: if the row says "active" or any liveness
|
|
# state where the pack is in fact loaded, honour the pack presence
|
|
# as the source of truth (DB rows can drift after manager restarts).
|
|
current_status = (
|
|
connector_manager._statuses.get(connector_id)
|
|
if connector_manager is not None
|
|
else None
|
|
)
|
|
state = "active" if current_status == ConnectorStatus.active else "inactive"
|
|
|
|
return ConnectorToolsResponse(
|
|
connector_id=connector_id,
|
|
state=state,
|
|
tools=tools,
|
|
)
|
|
|
|
def _heal_status_to_active(self, connector_id: str) -> None:
|
|
"""Refresh a non-active row to 'active' after a successful liveness probe.
|
|
|
|
Best-effort: a DB write failure here must NOT bubble up and turn a
|
|
successful test_connection into a failure response to the user.
|
|
Skips the write when status is already 'active' to avoid pointless
|
|
UPDATEs (and gmt_modified churn) on every test click.
|
|
"""
|
|
try:
|
|
with self._dao.session() as session:
|
|
entity = (
|
|
session.query(ConnectorInstanceEntity)
|
|
.filter(ConnectorInstanceEntity.connector_id == connector_id)
|
|
.first()
|
|
)
|
|
if entity is None or entity.status == "active":
|
|
return
|
|
previous_status = entity.status
|
|
entity.status = "active"
|
|
session.flush()
|
|
logger.info(
|
|
"Connector '%s' status healed from '%s' to 'active' "
|
|
"after successful test_connection",
|
|
connector_id,
|
|
previous_status,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to heal status for connector '%s'; "
|
|
"test_connection result is unaffected",
|
|
connector_id,
|
|
)
|
|
|
|
async def test_connection(self, connector_id: str) -> Dict[str, Any]:
|
|
"""Test the connection for a connector instance — actual SSE handshake.
|
|
|
|
Args:
|
|
connector_id (str): The connector UUID.
|
|
|
|
Returns:
|
|
Dict[str, Any]: ``{"success": bool, "message": str}``.
|
|
|
|
Raises:
|
|
ValueError: If the connector is not found in DB.
|
|
"""
|
|
connector = self.get_connector(connector_id)
|
|
if connector is None:
|
|
raise ValueError(f"Connector '{connector_id}' not found")
|
|
|
|
if self._is_native_type(connector.connector_type):
|
|
probed = await self._native_executor().probe_stored(connector_id)
|
|
success = bool(probed.get("success"))
|
|
if success:
|
|
self._heal_status_to_active(connector_id)
|
|
self.record_last_test(connector_id, success=success)
|
|
result: Dict[str, Any] = {
|
|
"success": success,
|
|
"message": str(probed.get("message") or ""),
|
|
}
|
|
if isinstance(probed.get("status_code"), int):
|
|
result["status_code"] = probed["status_code"]
|
|
if probed.get("error_type"):
|
|
result["error_type"] = probed["error_type"]
|
|
return result
|
|
|
|
cm = self.system_app.get_component(
|
|
"connector_manager", ConnectorManager, default_component=None
|
|
)
|
|
if cm is None:
|
|
return {"success": False, "message": "ConnectorManager 未就绪"}
|
|
|
|
pack = cm.get_connector_tools(connector_id)
|
|
if pack is None:
|
|
return {
|
|
"success": False,
|
|
"message": "连接器未激活,请检查 server_uri/凭证后重新激活",
|
|
}
|
|
|
|
try:
|
|
# 真实测试:重新跑 SSE handshake + tools/list
|
|
await asyncio.wait_for(pack.preload_resource(), timeout=10.0)
|
|
|
|
tool_count = sum(1 for t in pack.sub_resources if isinstance(t, BaseTool))
|
|
|
|
# Self-heal: a successful handshake proves the connector works now.
|
|
# Without this, a row stamped 'error' by a past transient PUT
|
|
# failure stays 'error' forever — nothing else flips it back.
|
|
self._heal_status_to_active(connector_id)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"连接成功,发现 {tool_count} 个工具",
|
|
}
|
|
except asyncio.TimeoutError:
|
|
logger.warning("test_connection timeout (>10s) for '%s'", connector_id)
|
|
return {"success": False, "message": "连接超时(>10s)"}
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"test_connection failed for '%s': %s",
|
|
connector_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"message": "连接失败,请检查 server_uri 与凭证(详情见服务端日志)",
|
|
}
|