Add the connector catalog and read-only connections API

connector_sessions already holds one row per signed-in account or MCP
server. Migration 0038 names each row (connector_key, auth_kind,
display_name, account_label) and lets sources and tools point at the
row they use through connection_id, backfilling the links it can prove.

docsgpt/connectors/catalog.py declares every service DocsGPT connects
to, with the server settings it needs and what a connection provides.
New endpoints: GET /api/connectors/catalog, GET /api/connections,
GET /api/connections/<id> and POST /api/connections/<id>/disconnect.
/api/available_tools now groups tools into built_in, service and
custom, and source listings carry connectionId.
This commit is contained in:
arc53-machine committed 2026-09-28 16:30:48 +01:00
1 parent 335f2af70c
commit 5321a94388
17 files changed
+1714 -2

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
"""Connectors catalog and connections API.
``/api/connectors/catalog`` lists every service DocsGPT can connect to, with
whether the server is set up for it and the caller's connection summary.
``/api/connections`` lists and manages the caller's connections. Responses
never include tokens or secrets.
"""
from __future__ import annotations
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
from docsgpt.api import api
from docsgpt.api.user.authz import ROLE_ADMIN, has_role
from docsgpt.connectors import service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
from docsgpt.storage.db.session import db_readonly, db_session
connections_ns = Namespace("connections", description="Connectors and connections", path="/api")
api.add_namespace(connections_ns)
def _user_id() -> str | None:
token = getattr(request, "decoded_token", None)
return token.get("sub") if isinstance(token, dict) else None
def _unauthorized():
return make_response(jsonify({"success": False, "error": "Unauthorized"}), 401)
def _not_found():
return make_response(jsonify({"success": False, "error": "Connection not found"}), 404)
@connections_ns.route("/connectors/catalog")
class ConnectorCatalog(Resource):
@api.doc(description="Every connector with its availability and the caller's connection summary")
def get(self):
user_id = _user_id()
if not user_id:
return _unauthorized()
try:
with db_readonly() as conn:
entries = service.catalog_for_user(
conn, user_id, is_admin=has_role(request.decoded_token, ROLE_ADMIN),
)
except Exception as err:
current_app.logger.error(f"Error building connector catalog: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to load connectors"}), 500)
return make_response(jsonify({"success": True, "connectors": entries}), 200)
@connections_ns.route("/connections")
class ConnectionsList(Resource):
@api.doc(description="The caller's connections with status and linked resource counts")
def get(self):
user_id = _user_id()
if not user_id:
return _unauthorized()
try:
with db_readonly() as conn:
connections = service.list_connections(conn, user_id)
except Exception as err:
current_app.logger.error(f"Error listing connections: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to load connections"}), 500)
return make_response(jsonify({"success": True, "connections": connections}), 200)
@connections_ns.route("/connections/<string:connection_id>")
class ConnectionDetail(Resource):
@api.doc(description="One connection with the sources it syncs and the tools it provides")
def get(self, connection_id: str):
user_id = _user_id()
if not user_id:
return _unauthorized()
try:
with db_readonly() as conn:
row = ConnectorSessionsRepository(conn).get_for_user(connection_id, user_id)
if row is None:
return _not_found()
detail = service.connection_detail(conn, row)
except Exception as err:
current_app.logger.error(f"Error loading connection: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to load connection"}), 500)
return make_response(jsonify({"success": True, "connection": detail}), 200)
@connections_ns.route("/connections/<string:connection_id>/disconnect")
class ConnectionDisconnect(Resource):
@api.doc(description="Delete a connection's stored credentials; its sources and tools stay")
def post(self, connection_id: str):
user_id = _user_id()
if not user_id:
return _unauthorized()
try:
with db_session() as conn:
row = ConnectorSessionsRepository(conn).get_for_user(connection_id, user_id)
if row is None:
return _not_found()
connection = service.disconnect(conn, row)
except Exception as err:
current_app.logger.error(f"Error disconnecting connection: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to disconnect"}), 500)
return make_response(jsonify({"success": True, "connection": connection}), 200)
+1
View File
@@ -357,6 +357,7 @@ DENIED_PREFIXES = (
"/api/admin/",
"/api/auth/oidc/",
"/api/connectors/",
"/api/connections",
"/api/devices",
"/scim/",
"/static/",
+8
View File
@@ -67,6 +67,12 @@ def _get_provider_from_remote_data(remote_data):
return None
def _connection_id(row: dict) -> str | None:
"""The connection a source syncs from, as a string id, or None."""
value = row.get("connection_id")
return str(value) if value else None
@sources_ns.route("/sources")
class CombinedJson(Resource):
@api.doc(description="Provide JSON file with combined available indexes")
@@ -117,6 +123,7 @@ class CombinedJson(Resource):
"config": SourceConfig.parse(index.get("config")).model_dump(),
"ownership": ownership,
"team_access": team_access,
"connectionId": _connection_id(index),
}
for index in indexes:
@@ -207,6 +214,7 @@ class PaginatedSources(Resource):
"team_access": (
None if owned else team_shared.get(str(doc["id"]))
),
"connectionId": _connection_id(doc),
}
)
response = {
+14
View File
@@ -19,6 +19,7 @@ from docsgpt.api import api
from docsgpt.api.pat.rules import filter_listing
from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact
from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access
from docsgpt.connectors.catalog import definition_for_tool
from docsgpt.core.settings import settings
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
@@ -186,6 +187,10 @@ def transform_actions(actions_metadata):
tools_ns = Namespace("tools", description="Tool management operations", path="/api")
# Tools the Connectors page adds through "Add custom connector" rather than
# the Add Tool modal.
_CUSTOM_CONNECTOR_TOOLS = {"mcp_tool": "custom_mcp", "api_tool": "custom_openapi"}
@tools_ns.route("/available_tools")
class AvailableTools(Resource):
@@ -202,6 +207,13 @@ class AvailableTools(Resource):
description = lines[1].strip() if len(lines) > 1 else ""
config_req = tool_instance.get_config_requirements()
actions = tool_instance.get_actions_metadata()
definition = definition_for_tool(tool_name)
if definition is not None:
group, connector_key = "service", definition.key
elif tool_name in _CUSTOM_CONNECTOR_TOOLS:
group, connector_key = "custom", _CUSTOM_CONNECTOR_TOOLS[tool_name]
else:
group, connector_key = "built_in", None
tools_metadata.append(
{
"name": tool_name,
@@ -209,6 +221,8 @@ class AvailableTools(Resource):
"description": description,
"configRequirements": config_req,
"actions": actions,
"group": group,
"connector_key": connector_key,
}
)
except Exception as err: